@guanlin4924/devflow-pi 0.1.40 → 0.1.48

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 (50) hide show
  1. package/bin/apply-opencode-go-session-hotfix.mjs +61 -0
  2. package/package.json +2 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
  4. package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
  5. package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
  6. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
  7. package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
  8. package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
  9. package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
  11. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
  21. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
  22. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
  23. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
  24. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
  25. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
  26. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
  27. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
  28. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
  29. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
  30. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
  31. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
  32. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
  33. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
  34. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
  35. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
  36. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
  37. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
  38. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
  39. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
  40. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
  41. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
  42. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
  43. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
  44. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
  45. package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
  46. package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
  47. package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
  48. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
  49. package/src/constants.ts +1 -1
  50. package/src/runtime.ts +53 -22
@@ -5,6 +5,7 @@ import json
5
5
  import os
6
6
  import shutil
7
7
  import sqlite3
8
+ import subprocess
8
9
  import sys
9
10
  import tempfile
10
11
  import uuid
@@ -14,8 +15,6 @@ from typing import Dict, Iterable, List, Set, Tuple
14
15
 
15
16
  import fcntl
16
17
 
17
- sys.dont_write_bytecode = True
18
-
19
18
  SCRIPT_DIR = Path(__file__).resolve().parent
20
19
  if str(SCRIPT_DIR) not in sys.path:
21
20
  sys.path.insert(0, str(SCRIPT_DIR))
@@ -58,13 +57,14 @@ def _index_lock(output: Path, exclusive: bool):
58
57
  fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
59
58
 
60
59
 
61
- def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int]) -> None:
60
+ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int], workspace_state: Dict[str, object]) -> None:
62
61
  generation = str(uuid.uuid4())
63
62
  snapshot = {
64
63
  "schemaVersion": 1,
65
64
  "generation": generation,
66
65
  "generatedAt": datetime.now(timezone.utc).isoformat(),
67
66
  "counts": counts,
67
+ "workspace": workspace_state,
68
68
  "artifacts": [*INDEX_FILES.values(), "index.sqlite", *MARKDOWN_FILES],
69
69
  }
70
70
  snapshot_path = staging / SNAPSHOT_FILE
@@ -72,8 +72,6 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int]) -> No
72
72
  with snapshot_path.open("rb") as handle:
73
73
  os.fsync(handle.fileno())
74
74
 
75
- # Readers in this module hold the shared lock. SNAPSHOT.json is published
76
- # last and is therefore the commit marker for external consumers.
77
75
  for name in snapshot["artifacts"]:
78
76
  os.replace(staging / name, output / name)
79
77
  os.replace(snapshot_path, output / SNAPSHOT_FILE)
@@ -84,6 +82,53 @@ def _publish_snapshot(staging: Path, output: Path, counts: Dict[str, int]) -> No
84
82
  os.close(directory_fd)
85
83
 
86
84
 
85
+ def _workspace_state(root: Path, files: List[dict], changed: Set[str]) -> Dict[str, object]:
86
+ indexed_paths = sorted(record["path"] for record in files)
87
+ source_manifest_hash = _hash_lines(f"{record['path']}\0{record['content_hash']}" for record in files)
88
+ return {
89
+ "repositoryRevision": _git_output(root, ["rev-parse", "HEAD"]),
90
+ "contentManifestHash": source_manifest_hash,
91
+ "indexedPathsHash": _hash_lines(indexed_paths),
92
+ "dirtyPathsAtBuild": sorted(changed),
93
+ }
94
+
95
+
96
+ def _worktree_signature(root: Path) -> str:
97
+ try:
98
+ proc = subprocess.run(
99
+ ["git", "status", "--porcelain=v1", "--untracked-files=all"],
100
+ cwd=str(root),
101
+ text=True,
102
+ stdout=subprocess.PIPE,
103
+ stderr=subprocess.DEVNULL,
104
+ check=False,
105
+ )
106
+ except (OSError, FileNotFoundError):
107
+ return ""
108
+ if proc.returncode != 0:
109
+ return ""
110
+ kept = []
111
+ for line in proc.stdout.splitlines():
112
+ path = line[3:].strip() if len(line) > 3 else ""
113
+ if path.startswith(".planning/code-index/"):
114
+ continue
115
+ kept.append(line)
116
+ return "\n".join(kept)
117
+
118
+
119
+ def _git_output(root: Path, args: List[str]) -> str:
120
+ try:
121
+ proc = subprocess.run(["git", *args], cwd=str(root), text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
122
+ except (OSError, FileNotFoundError):
123
+ return ""
124
+ return proc.stdout.strip() if proc.returncode == 0 else ""
125
+
126
+
127
+ def _hash_lines(values) -> str:
128
+ digest = uuid.uuid5(uuid.NAMESPACE_URL, "\n".join(values))
129
+ return f"uuid5:{digest}"
130
+
131
+
87
132
  def main(argv=None) -> int:
88
133
  parser = argparse.ArgumentParser(description="Build and validate a generic code intelligence index.")
89
134
  sub = parser.add_subparsers(dest="command", required=True)
@@ -201,6 +246,7 @@ def command_scan(args) -> int:
201
246
  "relations": len(relations),
202
247
  "diagnostics": len(diagnostics),
203
248
  },
249
+ _workspace_state(root, files, changed),
204
250
  )
205
251
  finally:
206
252
  shutil.rmtree(staging, ignore_errors=True)
@@ -212,38 +258,6 @@ def command_scan(args) -> int:
212
258
 
213
259
  def command_validate(args) -> int:
214
260
  output = Path(args.index).resolve()
215
- with _index_lock(output, exclusive=False):
216
- files, symbols, relations, diagnostics = _load_tuple(output)
217
- errors = validate_index(files, symbols, relations, diagnostics)
218
- if errors:
219
- for error in errors:
220
- print(error)
221
- return 1
222
- print("Code index schema validation passed.")
223
- return 0
224
-
225
-
226
- def command_audit(args) -> int:
227
- root = Path(args.root).resolve()
228
- output = Path(args.index).resolve()
229
- with _index_lock(output, exclusive=True):
230
- files, symbols, relations, diagnostics = _load_tuple(output)
231
- findings = []
232
- findings.extend(audit_stale(root, files))
233
- findings.extend(audit_relations(symbols, relations))
234
- skill_root = Path(args.skill_root)
235
- if skill_root.exists():
236
- findings.extend(audit_project_binding([skill_root / "workflow-code-index", skill_root / "code-index-query"]))
237
- write_audit(output, findings)
238
- if findings:
239
- for finding in findings[:200]:
240
- location = f" {finding.get('path', '')}:{finding.get('line', '')}".rstrip(":")
241
- print(f"{finding.get('severity', 'warning')}: {finding.get('kind', 'unknown')}: {finding.get('message', '')}{location}")
242
- return 1
243
- print("Code index audit passed.")
244
- return 0
245
-
246
-
247
261
  def command_query(args) -> int:
248
262
  output = Path(args.index).resolve()
249
263
  with _index_lock(output, exclusive=False):
@@ -251,6 +265,11 @@ def command_query(args) -> int:
251
265
  if not index.exists():
252
266
  print(f"Missing index: {index}", file=sys.stderr)
253
267
  return 1
268
+ snapshot = _load_snapshot(output)
269
+ files = read_jsonl(output / INDEX_FILES["files"])
270
+ if not _query_is_fresh(output.parent.parent, snapshot, files):
271
+ print("Stale code index: refresh required before query.", file=sys.stderr)
272
+ return 1
254
273
  conn = sqlite3.connect(str(index))
255
274
  conn.row_factory = sqlite3.Row
256
275
  try:
@@ -283,6 +302,57 @@ def command_query(args) -> int:
283
302
  return 0
284
303
 
285
304
 
305
+ def _load_snapshot(output: Path) -> Dict[str, object]:
306
+ path = output / SNAPSHOT_FILE
307
+ if not path.exists():
308
+ return {}
309
+ return json.loads(path.read_text(encoding="utf-8"))
310
+
311
+
312
+ def _query_is_fresh(root: Path, snapshot: Dict[str, object], files: List[dict]) -> bool:
313
+ workspace = snapshot.get("workspace") if isinstance(snapshot, dict) else None
314
+ if not isinstance(workspace, dict):
315
+ return False
316
+ if workspace.get("repositoryRevision") and workspace.get("repositoryRevision") != _git_output(root, ["rev-parse", "HEAD"]):
317
+ return False
318
+ indexed_manifest = _hash_lines(f"{record['path']}\0{record['content_hash']}" for record in files)
319
+ if workspace.get("contentManifestHash") != indexed_manifest:
320
+ return False
321
+ return True
322
+
323
+
324
+
325
+ def command_audit(args) -> int:
326
+ root = Path(args.root).resolve()
327
+ output = Path(args.index).resolve()
328
+ with _index_lock(output, exclusive=True):
329
+ files, symbols, relations, diagnostics = _load_tuple(output)
330
+ findings = []
331
+ findings.extend(audit_stale(root, files))
332
+ findings.extend(audit_relations(symbols, relations))
333
+ skill_root = Path(args.skill_root)
334
+ if skill_root.exists():
335
+ findings.extend(audit_project_binding([skill_root / "workflow-code-index", skill_root / "code-index-query"]))
336
+ write_audit(output, findings)
337
+ if findings:
338
+ for finding in findings[:200]:
339
+ location = f" {finding.get('path', '')}:{finding.get('line', '')}".rstrip(":")
340
+ print(f"{finding.get('severity', 'warning')}: {finding.get('kind', 'unknown')}: {finding.get('message', '')}{location}")
341
+ return 1
342
+ print("Code index audit passed.")
343
+ return 0
344
+
345
+
346
+ def _load_snapshot(output: Path) -> Dict[str, object]:
347
+ path = output / SNAPSHOT_FILE
348
+ if not path.exists():
349
+ return {}
350
+ return json.loads(path.read_text(encoding="utf-8"))
351
+
352
+
353
+
354
+
355
+
286
356
  def _load_tuple(output: Path):
287
357
  return (
288
358
  read_jsonl(output / INDEX_FILES["files"]),
@@ -6,6 +6,7 @@ from typing import Iterable, List, Sequence
6
6
 
7
7
  DEFAULT_EXCLUDES = {
8
8
  ".git",
9
+ ".planning",
9
10
  "node_modules",
10
11
  "vendor",
11
12
  "dist",
@@ -59,7 +60,7 @@ TEXT_EXTENSIONS = {
59
60
  def scan_files(root: Path, excludes: Iterable[str] = (), max_bytes: int = 1_500_000) -> List[Path]:
60
61
  root = root.resolve()
61
62
  excluded = DEFAULT_EXCLUDES | set(excludes)
62
- paths = _scan_with_rg(root, excluded) or _scan_with_walk(root, excluded)
63
+ paths = _scan_with_git(root, excluded) or _scan_with_rg(root, excluded) or _scan_with_walk(root, excluded)
63
64
  result: List[Path] = []
64
65
  for path in sorted(paths):
65
66
  if not path.is_file():
@@ -77,6 +78,24 @@ def scan_files(root: Path, excludes: Iterable[str] = (), max_bytes: int = 1_500_
77
78
  return result
78
79
 
79
80
 
81
+ def _scan_with_git(root: Path, excluded: Sequence[str]) -> List[Path]:
82
+ try:
83
+ proc = subprocess.run(
84
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
85
+ cwd=str(root),
86
+ text=True,
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.DEVNULL,
89
+ check=False,
90
+ )
91
+ except (OSError, FileNotFoundError):
92
+ return []
93
+ if proc.returncode != 0:
94
+ return []
95
+ return [root / line.strip() for line in proc.stdout.splitlines() if line.strip()]
96
+
97
+
98
+
80
99
  def _scan_with_rg(root: Path, excluded: Sequence[str]) -> List[Path]:
81
100
  ignore_args = []
82
101
  for name in excluded:
@@ -12,7 +12,7 @@ description: "Use after integration verification and before change planning to d
12
12
  本步骤的图谱写入受 ExecutionContract v2 约束:`devflow_requirement_graph(action="propose", kind="test_case")` 与 `devflow_api_test_matrix(action="import")` 仅在 active contract 的 step 为 `acceptance-test-design` 时放行。步骤开始且无 active contract 时:
13
13
 
14
14
  1. 先调用 `devflow_contract(action="status")` 检查当前会话是否有 active contract;
15
- 2. 未激活时调用 `devflow_contract(action="activate", feature="<feature>", step="acceptance-test-design")` 合成并激活(planning / waiting-user / awaiting-approval 阶段均可,Core 校验 STATE 与图谱 revision 后由用户确认);
15
+ 2. 未激活时调用 `devflow_contract(action="activate", feature="<feature>", step="acceptance-test-design")` 合成并激活(planning / waiting-user / awaiting-approval,或 Runtime 已触发当前步骤的 executing 回合均可;Core 校验 STATE 当前 feature/step 与图谱 revision 后由用户确认);
16
16
  3. 会话中断后 contract 由 runtime 在 `session_start` 时从 `.planning/contracts/{feature}/EC-*.json` 自动恢复;恢复时校验 state/graph revision,推进后自动失效,需重新合成。
17
17
 
18
18
  ## 输入
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.40";
2
+ export const PRODUCT_VERSION = "0.1.48";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5
 
package/src/runtime.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { mkdir, readFile, rm } from "node:fs/promises";
2
+ import { homedir } from "node:os";
2
3
  import path from "node:path";
3
4
 
4
5
  import { OMP_RUNTIME_VERSION, PRODUCT_NAME, PRODUCT_VERSION } from "./constants.ts";
5
6
  import { createPlatformExtension } from "./platform-extension.ts";
6
7
  import { devflowAgentDir, devflowHome, pluginRoots, projectSessionDir, sharedOmpAgentDir } from "./paths.ts";
7
8
  import type { ProjectContext } from "./project-context.ts";
9
+ import { injectOmpExtensionCliRoots } from "@oh-my-pi/pi-coding-agent/discovery/omp-extension-roots";
8
10
 
9
11
  export interface RuntimeOptions {
10
12
  initialPrompt?: string;
@@ -102,6 +104,12 @@ export async function createEmbeddedRuntime(project: ProjectContext, options: Ru
102
104
  utils.logger.setTransports({ file: path.join(productHome, "logs") });
103
105
  const omp = await import("@oh-my-pi/pi-coding-agent");
104
106
  const roots = pluginRoots();
107
+ // Task workers perform a fresh agent discovery after session creation. The
108
+ // SDK's temporary extension-root scope does not cover that later lookup,
109
+ // so register Devflow package roots as durable CLI roots for this process.
110
+ // Without this, packaged agents such as implementation-worker appear during
111
+ // startup but fail with Unknown agent when a parallel task is dispatched.
112
+ injectOmpExtensionCliRoots(roots, homedir(), project.root, { mode: "explicit-only", replace: true });
105
113
  // Reuse credentials and model discovery from the user's OMP agent, but not
106
114
  // its config.yml: a global modelRoles block would override choices made in
107
115
  // Devflow's own Roles screen after every restart.
@@ -134,28 +142,37 @@ export async function createEmbeddedRuntime(project: ProjectContext, options: Ru
134
142
  } else {
135
143
  sessionManager = omp.SessionManager.create(project.root, sessionDir);
136
144
  }
137
- const runtime = await omp.createAgentSession({
138
- cwd: project.root,
139
- agentDir: productAgentDir,
140
- settings,
141
- authStorage,
142
- modelRegistry,
143
- sessionManager,
144
- additionalExtensionPaths: roots,
145
- disableExtensionDiscovery: true,
146
- extensions: [
147
- createPlatformExtension({
148
- sessionRoot: sessionDir,
149
- ompSessionRoot: path.join(sharedAgentDir, "sessions"),
150
- projectId: project.manifest.projectId,
151
- getConfiguredRole: role => settings.getModelRole(role),
152
- }),
153
- ],
154
- hasUI: options.hasUI ?? true,
155
- enableMCP: options.enableMCP ?? false,
156
- enableLsp: options.enableLsp ?? true,
157
- appendSystemPrompt: TOOL_CALL_SAFETY_PROMPT,
158
- });
145
+ const previousCliResumeSignal = process.env.DEVFLOW_CLI_SESSION_RESUME;
146
+ if (options.resume) process.env.DEVFLOW_CLI_SESSION_RESUME = "1";
147
+ else delete process.env.DEVFLOW_CLI_SESSION_RESUME;
148
+ let runtime;
149
+ try {
150
+ runtime = await omp.createAgentSession({
151
+ cwd: project.root,
152
+ agentDir: productAgentDir,
153
+ settings,
154
+ authStorage,
155
+ modelRegistry,
156
+ sessionManager,
157
+ additionalExtensionPaths: roots,
158
+ disableExtensionDiscovery: true,
159
+ extensions: [
160
+ createPlatformExtension({
161
+ sessionRoot: sessionDir,
162
+ ompSessionRoot: path.join(sharedAgentDir, "sessions"),
163
+ projectId: project.manifest.projectId,
164
+ getConfiguredRole: role => settings.getModelRole(role),
165
+ }),
166
+ ],
167
+ hasUI: options.hasUI ?? true,
168
+ enableMCP: options.enableMCP ?? false,
169
+ enableLsp: options.enableLsp ?? true,
170
+ appendSystemPrompt: TOOL_CALL_SAFETY_PROMPT,
171
+ });
172
+ } finally {
173
+ if (previousCliResumeSignal === undefined) delete process.env.DEVFLOW_CLI_SESSION_RESUME;
174
+ else process.env.DEVFLOW_CLI_SESSION_RESUME = previousCliResumeSignal;
175
+ }
159
176
  return { omp, runtime, sessionDir };
160
177
  }
161
178
 
@@ -256,6 +273,15 @@ export async function runInteractiveRuntime(project: ProjectContext, options: Ru
256
273
  void consumeControlledRollover();
257
274
  }, delay);
258
275
  };
276
+ // A control-plane request may be created by an extension input handler that
277
+ // marks the message handled, so it is not guaranteed to pass through the
278
+ // normal submitInteractiveInput return path. Keep a small runtime-owned
279
+ // poller alive for the lifetime of the interactive loop; it only touches the
280
+ // filesystem when a durable rollover request exists.
281
+ const rolloverPoller = setInterval(() => {
282
+ void consumeControlledRollover();
283
+ }, 500);
284
+ rolloverPoller.unref?.();
259
285
  // A prepared handoff is a durable runtime request, not something that
260
286
  // should wait for the user to type another command after a restart.
261
287
  await consumeControlledRollover();
@@ -263,5 +289,10 @@ export async function runInteractiveRuntime(project: ProjectContext, options: Ru
263
289
  const input = await mode.getUserInput();
264
290
  await omp.submitInteractiveInput(mode, runtime.session, input);
265
291
  await consumeControlledRollover();
292
+ // The submitted turn may create a durable rollover request after the
293
+ // immediate check above returns. Poll once while the UI is waiting for the
294
+ // next user input so a successful step does not require a dummy message to
295
+ // trigger the fresh-session handoff.
296
+ scheduleControlledRolloverRetry();
266
297
  }
267
298
  }