@guanlin4924/devflow-pi 0.1.0 → 0.1.4

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
@@ -27,6 +27,7 @@ devflow-pi
27
27
  /devflow doctor
28
28
  /devflow project-init
29
29
  /devflow explain-state
30
+ /devflow recover
30
31
  /devflow start <feature>|<phase>
31
32
  /devflow continue
32
33
  /devflow advance
@@ -114,7 +115,7 @@ bash .skills/install.sh --apply --force-config
114
115
 
115
116
  首次使用时,在 OMP 的模型角色界面中分别为 `smol`、`task`、`slow` 选择具体模型;`modelRoleStorage: project` 会把选择保存在项目作用域。建议分别使用快速探索模型、代码实现模型和独立推理/审查模型。配置完成后以 `/devflow doctor` 的 `models:` 行为准。
116
117
 
117
- `/devflow start` 只创建 Feature 状态,不会自动启动整条流程。`/devflow continue` 只运行当前一个工作流步骤并停止;产物审查后由用户显式执行 `/devflow advance`,checkpoint 也必须由用户通过 `/devflow checkpoint` 确认。恢复已有流程使用 `/devflow resume`,它同样只恢复状态而不自动执行。普通中文输入“初始化项目”“查看状态”“需求:……”会进入相应的受控只读分诊,不会偷偷启动实现或状态迁移。
118
+ `/devflow start` 只创建 Feature 状态,不会自动启动整条流程。`/devflow continue` 只运行当前一个工作流步骤并停止;产物审查后由用户显式执行 `/devflow advance`,checkpoint 也必须由用户通过 `/devflow checkpoint` 确认。`/devflow explain-state` 发现控制面和制品 registry 漂移时会给出只读恢复计划;用户审查后可用 `/devflow recover` 确认仅修复可由文件系统证明的 registry 字段,它绝不推进步骤或自动通过 checkpoint。恢复已有流程使用 `/devflow resume`,它同样只恢复状态而不自动执行。普通中文输入“初始化项目”“查看状态”“需求:……”会进入相应的受控只读分诊,不会偷偷启动实现或状态迁移。
118
119
 
119
120
  ## Marketplace 能力包
120
121
 
package/bin/devflow-pi ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import "../src/cli.ts";
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
7
7
  "bin": {
8
- "devflow-pi": "./src/cli.ts"
8
+ "devflow-pi": "bin/devflow-pi"
9
9
  },
10
10
  "scripts": {
11
11
  "dev": "bun run src/cli.ts",
@@ -25,6 +25,7 @@
25
25
  "typescript": "^5.9.3"
26
26
  },
27
27
  "files": [
28
+ "bin",
28
29
  "src",
29
30
  "plugins",
30
31
  "!plugins/**/__pycache__",
@@ -46,6 +46,7 @@ const STATE_MUTATION_TOOLS = new Set([
46
46
  "devflow_bootstrap",
47
47
  "devflow_transition",
48
48
  "devflow_artifact",
49
+ "devflow_recovery",
49
50
  "devflow_context_sync",
50
51
  "devflow_evidence",
51
52
  ]);
@@ -163,6 +164,7 @@ const REQUIRED_EXTENSION_TOOLS = [
163
164
  "devflow_bootstrap",
164
165
  "devflow_transition",
165
166
  "devflow_artifact",
167
+ "devflow_recovery",
166
168
  "devflow_skill_script",
167
169
  "devflow_context_sync",
168
170
  "devflow_code_index",
@@ -593,11 +595,8 @@ export default function devflowCore(pi: ExtensionAPI) {
593
595
  let sessionId = randomUUID();
594
596
  let controlState: SessionControlState = initialControlState();
595
597
  let activeContract: ActiveExecutionContract | undefined;
596
- let toolCalls = 0;
597
- const toolCallBudget = 40;
598
598
 
599
599
  function setControlState(ctx: { ui: { setStatus(key: string, text: string | undefined): void } }, state: SessionControlState) {
600
- if (state.phase === "executing" && controlState.phase !== "executing") toolCalls = 0;
601
600
  controlState = state;
602
601
  ctx.ui.setStatus("devflow-control", statusText(state));
603
602
  }
@@ -925,13 +924,6 @@ export default function devflowCore(pi: ExtensionAPI) {
925
924
  });
926
925
 
927
926
  pi.on("tool_call", async (event, ctx) => {
928
- toolCalls += 1;
929
- if (toolCalls > toolCallBudget && controlState.phase === "executing") {
930
- return {
931
- block: true,
932
- reason: `本步骤已达到工具调用上限(${toolCallBudget})。已自动停止,请检查证据后使用 /devflow advance 或重新 /devflow continue。`,
933
- };
934
- }
935
927
  const toolName = String(event.toolName).toLowerCase();
936
928
  if (toolName === "task") {
937
929
  const requested = requestedTaskAgents(event.input);
@@ -1347,6 +1339,26 @@ export default function devflowCore(pi: ExtensionAPI) {
1347
1339
  },
1348
1340
  });
1349
1341
 
1342
+ pi.registerTool({
1343
+ name: "devflow_recovery",
1344
+ label: "Devflow Recovery",
1345
+ description: "Apply a previewed, registry-only control-plane repair. Requires explicit confirmation and the inspected state revision; never advances a workflow.",
1346
+ parameters: z.object({
1347
+ expectedRevision: z.number().int().min(0),
1348
+ confirm: z.literal(true),
1349
+ state: z.string().optional(),
1350
+ }),
1351
+ approval: "write",
1352
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1353
+ const args = ["--write", "--confirm", "--expected-revision", String(params.expectedRevision), "--json"];
1354
+ if (params.state) args.push("--state", params.state);
1355
+ return renderResult(
1356
+ await runRuby(ctx.cwd, "workflow-orchestrator/scripts/repair_control_plane.rb", args),
1357
+ { action: "repair-control-plane" },
1358
+ );
1359
+ },
1360
+ });
1361
+
1350
1362
  pi.registerTool({
1351
1363
  name: "devflow_skill_script",
1352
1364
  label: "Devflow Skill Script",
@@ -1415,6 +1427,36 @@ export default function devflowCore(pi: ExtensionAPI) {
1415
1427
  setControlState(ctx, initialControlState());
1416
1428
  return;
1417
1429
  }
1430
+ if (trimmed === "recover") {
1431
+ const inspected = await inspectWorkflow(ctx.cwd);
1432
+ const plan = inspected.snapshot?.recovery_plan as Record<string, unknown> | undefined;
1433
+ const revision = plan?.expected_revision;
1434
+ if (!inspected.result.ok || !plan || plan.classification !== "safe_repairable" || typeof revision !== "number") {
1435
+ ctx.ui.notify("当前状态没有可安全执行的恢复计划;请先运行 /devflow explain-state,并按其中的阻塞证据处理。", "error");
1436
+ return;
1437
+ }
1438
+ const confirmed = await ctx.ui.confirm(
1439
+ "确认修复 Devflow 控制面",
1440
+ "将只根据已存在的文件同步 artifact registry;不会推进步骤、确认 checkpoint 或修改需求产物。继续吗?",
1441
+ );
1442
+ if (!confirmed) {
1443
+ ctx.ui.notify("恢复未确认;状态保持不变。", "info");
1444
+ return;
1445
+ }
1446
+ const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/repair_control_plane.rb", [
1447
+ "--write",
1448
+ "--confirm",
1449
+ "--expected-revision",
1450
+ String(revision),
1451
+ "--json",
1452
+ ]);
1453
+ if (!result.ok) {
1454
+ ctx.ui.notify(`控制面恢复失败:${result.stderr || result.stdout}`, "error");
1455
+ return;
1456
+ }
1457
+ ctx.ui.notify("控制面 registry 已修复;未推进工作流。请审查当前步骤产物后使用 /devflow advance。", "info");
1458
+ return;
1459
+ }
1418
1460
  if (trimmed === "project-init") {
1419
1461
  const summary = await collectProjectInitialization(ctx);
1420
1462
  if (!summary) return;
@@ -1634,7 +1676,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1634
1676
  return;
1635
1677
  }
1636
1678
  ctx.ui.notify(
1637
- "Usage: /devflow doctor | status | explain-state | project-init | start <feature>|<phase> | resume | continue | advance | checkpoint",
1679
+ "Usage: /devflow doctor | status | explain-state | recover | project-init | start <feature>|<phase> | resume | continue | advance | checkpoint",
1638
1680
  "warning",
1639
1681
  );
1640
1682
  },
@@ -37,6 +37,7 @@ Use the JSON result to choose one of these actions:
37
37
  | `run_project_context` | Load and execute `workflow-project-context`, then run the state inspector again. |
38
38
  | `run_next_step` | Load and execute the indicated `next_step.skill`. |
39
39
  | `run_current_step` | Re-run or finish the indicated `current_step.skill`. |
40
+ | `recover_artifact_registry` | Review `recovery_plan`; if it is `safe_repairable`, call `devflow_recovery(expectedRevision=<plan revision>, confirm=true)`, re-inspect, then explicitly complete the current step when its output is ready. |
40
41
  | `workflow_complete` | Report completion and do not call another workflow skill. |
41
42
  | `blocked` | Report the blocking reason and ask for the minimum missing input. |
42
43
 
@@ -188,6 +189,16 @@ revision is rejected without changing state, registry, or next-intent files.
188
189
 
189
190
  Checkpoint confirmation also writes the configured `.planning/.next-intent`.
190
191
 
192
+ ## Control-plane recovery
193
+
194
+ `devflow_state(action="inspect")` reports `recovery_plan` for output/registry drift. Only a plan classified `safe_repairable` may be applied, and it must be explicitly confirmed with the exact inspected revision:
195
+
196
+ ```text
197
+ devflow_recovery(expectedRevision=<recovery_plan.expected_revision>, confirm=true)
198
+ ```
199
+
200
+ This is registry-only: it derives file existence/content metadata from the filesystem and never changes `completed_steps`, `pending_steps`, `current_step`, checkpoint state, or workflow status. Plans classified `unsafe` (for example missing required output or conflicting state tracking) must be corrected manually; recovery refuses them.
201
+
191
202
  When `devflow_transition` succeeds, it automatically regenerates the Markdown body of STATE.md from the YAML state + artifact registry. The body is marked `<!-- GENERATED — DO NOT EDIT -->` and must not be manually edited.
192
203
 
193
204
  ## Lessons Rules
@@ -96,9 +96,9 @@ def content_validity(root, path, checks)
96
96
  { "valid" => issues.empty?, "issues" => issues }
97
97
  end
98
98
 
99
- def sync_artifact_registry(root, workflow, state)
99
+ def build_artifact_registry(root, workflow, state)
100
100
  registry_path = workflow.dig("artifact_registry", "source")
101
- return nil unless registry_path
101
+ return [nil, nil] unless registry_path
102
102
 
103
103
  abs = File.join(root, registry_path)
104
104
  existing_by_path = if File.exist?(abs)
@@ -159,6 +159,15 @@ def sync_artifact_registry(root, workflow, state)
159
159
  "artifacts" => artifacts
160
160
  }
161
161
 
162
+ [registry_path, registry]
163
+ end
164
+
165
+ def sync_artifact_registry(root, workflow, state, registry: nil)
166
+ registry_path = workflow.dig("artifact_registry", "source")
167
+ return nil unless registry_path
168
+
169
+ registry ||= build_artifact_registry(root, workflow, state).last
170
+ abs = File.join(root, registry_path)
162
171
  DevflowControlIO.atomic_write(abs, registry.to_yaml)
163
172
  registry_path
164
173
  end
@@ -479,10 +488,16 @@ result =
479
488
  state["state_updated_at"] = Date.today.to_s
480
489
  state["revision"] = current_revision + 1
481
490
  end
491
+ # Build the registry from the candidate state before validating it. A
492
+ # step's output is normally written before this transition, so the
493
+ # persisted registry may still describe the previous state revision.
494
+ # Validating against it would make complete-step self-blocking.
495
+ _candidate_registry_path, candidate_registry = changed ? build_artifact_registry(root, workflow, state) : [nil, nil]
482
496
  validation = WorkflowStateValidator.validate(
483
497
  root: root,
484
498
  state_path: state_path,
485
499
  state_override: state,
500
+ artifact_registry_override: candidate_registry,
486
501
  strict_artifact_status: false,
487
502
  strict_registry_revision: false
488
503
  )
@@ -490,7 +505,7 @@ result =
490
505
  if changed && !validation["ok"]
491
506
  result = fail_result("state_validation_failed", "validation" => validation)
492
507
  elsif changed && options[:write]
493
- sync_artifact_registry(root, workflow, state)
508
+ sync_artifact_registry(root, workflow, state, registry: candidate_registry)
494
509
  artifact_by_path = load_artifact_registry(root, workflow)
495
510
  write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
496
511
  next_intent_written = write_next_intent(root, workflow) if next_intent_requested
@@ -382,8 +382,49 @@ result =
382
382
  action: "blocked",
383
383
  reason: "state_validation_failed",
384
384
  state_validation: validation,
385
+ recovery_plan: {
386
+ classification: "unsafe",
387
+ state_transition: "none",
388
+ registry_write: false,
389
+ reasons: validation["errors"].map { |issue| issue["code"] }
390
+ },
385
391
  recovery: "Fix missing/invalid artifacts or call devflow_artifact with action=sync and preserve=false, then call devflow_state with action=inspect."
386
392
  }
393
+ elsif (drift = validation["warnings"].find { |issue| issue["code"] == "current_output_registry_drift" })
394
+ {
395
+ current_flow: state["current_flow"],
396
+ current_feature: state["current_feature"],
397
+ current_phase: state["current_phase"],
398
+ status: state["status"],
399
+ completed_steps: completed_ids,
400
+ pending_steps: pending_ids,
401
+ current_step: current_step && {
402
+ id: current_step["id"],
403
+ label: current_step["label"],
404
+ skill: current_step["skill"],
405
+ action: current_step["action"]
406
+ },
407
+ next_step: nil,
408
+ workflow_config: workflow_path,
409
+ artifact_registry: artifact_registry_summary(root, artifact_by_path).merge(
410
+ "path" => artifact_registry_path,
411
+ "error" => artifact_registry_error,
412
+ "current_outputs_registered" => 0
413
+ ),
414
+ action: "recover_artifact_registry",
415
+ reason: "current_output_registry_drift",
416
+ drift: drift,
417
+ recovery_plan: {
418
+ classification: "safe_repairable",
419
+ state_transition: "none",
420
+ registry_write: true,
421
+ confirmation_required: true,
422
+ expected_revision: state.fetch("revision", 0),
423
+ diff: [{ path: drift["path"], registry_exists: drift["registry_exists"], filesystem_exists: true,
424
+ registry_content_valid: drift["registry_content_valid"], filesystem_content_valid: true }]
425
+ },
426
+ recovery: "After reviewing this diff, call devflow_recovery(expectedRevision=#{state.fetch("revision", 0)}, confirm=true). It only reconciles registry metadata; explicitly complete the step separately when ready."
427
+ }
387
428
  elsif current_step.nil?
388
429
  fallback_next_id = next_pending_step_id(state, pending_ids, step_by_id, completed_ids)
389
430
  fallback_next_step = fallback_next_id && step_by_id[fallback_next_id]
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "date"
5
+ require "json"
6
+ require "optparse"
7
+ require "yaml"
8
+ require_relative "control_io"
9
+ require_relative "validate_state"
10
+
11
+ options = { state: ".planning/STATE.md", expected_revision: nil, confirm: false, write: false, json: false }
12
+ OptionParser.new do |opts|
13
+ opts.banner = "Usage: repair_control_plane.rb --expected-revision N --confirm --write"
14
+ opts.on("--state PATH") { |value| options[:state] = value }
15
+ opts.on("--expected-revision N", Integer) { |value| options[:expected_revision] = value }
16
+ opts.on("--confirm", "Required acknowledgement of the proposed registry-only repair") { options[:confirm] = true }
17
+ opts.on("--write", "Persist the repair") { options[:write] = true }
18
+ opts.on("--json") { options[:json] = true }
19
+ end.parse!
20
+
21
+ def front_matter(markdown)
22
+ markdown.match(/\A---\n(.*?)\n---\n/m)&.[](1)
23
+ end
24
+
25
+ def interpolate_path(path, state)
26
+ path.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
27
+ end
28
+
29
+ def build_registry(root, workflow, state)
30
+ checks = workflow.dig("artifact_registry", "content_checks") || {}
31
+ artifacts = Array(workflow["steps"]).flat_map do |step|
32
+ Array(step["outputs"]).map do |output|
33
+ path = interpolate_path(output.fetch("path"), state)
34
+ abs = File.join(root, path)
35
+ exists = File.exist?(abs)
36
+ issues = []
37
+ issues << "file_missing" unless exists
38
+ issues << "below_min_bytes(#{File.size(abs)} < #{checks["min_bytes"]})" if exists && checks["min_bytes"] && File.size(abs) < checks["min_bytes"]
39
+ if exists && checks["required_header"]
40
+ head = File.read(abs, [checks["required_header"].length + 50, 200].min)
41
+ issues << "missing_required_header(#{checks["required_header"].inspect})" unless head.include?(checks["required_header"])
42
+ end
43
+ completed = Array(state["completed_steps"]).include?(step.fetch("id"))
44
+ status = completed ? (step["checkpoint"] ? "approved" : "completed") : "planned"
45
+ entry = { "path" => path, "type" => output["type"], "workflow_step" => step.fetch("id"), "produced_by" => step["skill"] || step["action"], "status" => status, "exists" => exists, "content_valid" => issues.empty? }
46
+ entry["checkpoint"] = step["checkpoint"] if step["checkpoint"]
47
+ entry["content_issues"] = issues unless issues.empty?
48
+ entry
49
+ end
50
+ end
51
+ { "schema_version" => 1, "state_revision" => state.fetch("revision", 0), "updated_at" => Date.today.to_s,
52
+ "registry_scope" => { "flow" => state["current_flow"], "feature" => state["current_feature"], "phase" => state["current_phase"] }, "artifacts" => artifacts }
53
+ end
54
+
55
+ root = Dir.pwd
56
+ state_path = File.expand_path(options[:state], root)
57
+ result = nil
58
+ lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
59
+ begin
60
+ unless File.exist?(state_path)
61
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_state_file" }
62
+ else
63
+ state = YAML.safe_load(front_matter(File.read(state_path)), permitted_classes: [], aliases: false)
64
+ workflow_path = File.expand_path(state.fetch("workflow_config"), root)
65
+ workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
66
+ candidate = build_registry(root, workflow, state)
67
+ validation = WorkflowStateValidator.validate(root: root, state_path: state_path, state_override: state, workflow_override: workflow, artifact_registry_override: candidate, strict_artifact_status: true, strict_registry_revision: true)
68
+ current = Array(workflow["steps"]).find { |step| step["id"] == state["current_step"] }
69
+ missing_current_outputs = Array(current && current["outputs"]).map do |output|
70
+ next unless output["required"]
71
+ path = interpolate_path(output.fetch("path"), state)
72
+ path unless File.exist?(File.join(root, path))
73
+ end.compact
74
+ if missing_current_outputs.any?
75
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
76
+ elsif !validation["ok"]
77
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
78
+ elsif !options[:confirm] || !options[:write]
79
+ result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
80
+ elsif options[:expected_revision] != state.fetch("revision", 0)
81
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
82
+ else
83
+ registry_path = File.join(root, workflow.dig("artifact_registry", "source"))
84
+ DevflowControlIO.atomic_write(registry_path, candidate.to_yaml)
85
+ result = { "ok" => true, "classification" => "safe_repairable", "written" => true, "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true } }
86
+ end
87
+ end
88
+ ensure
89
+ DevflowControlIO.release_lock(lock)
90
+ end
91
+ puts JSON.pretty_generate(result)
@@ -21,7 +21,7 @@ module WorkflowStateValidator
21
21
 
22
22
  module_function
23
23
 
24
- def validate(root:, state_path:, state_override: nil, workflow_override: nil, strict_artifact_status: true, strict_registry_revision: true)
24
+ def validate(root:, state_path:, state_override: nil, workflow_override: nil, artifact_registry_override: nil, strict_artifact_status: true, strict_registry_revision: true)
25
25
  root = File.expand_path(root)
26
26
  state_path = File.expand_path(state_path, root)
27
27
  issues = []
@@ -45,7 +45,8 @@ module WorkflowStateValidator
45
45
  registry_path,
46
46
  issues,
47
47
  state_revision: state.fetch("revision", 0),
48
- strict_registry_revision: strict_registry_revision
48
+ strict_registry_revision: strict_registry_revision,
49
+ registry_override: artifact_registry_override
49
50
  )
50
51
 
51
52
  steps = Array(workflow["steps"])
@@ -60,6 +61,7 @@ module WorkflowStateValidator
60
61
  validate_dependencies(issues, step_by_id, completed_ids)
61
62
  validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
62
63
  validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
64
+ validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
63
65
 
64
66
  ok = issues.none? { |entry| entry["severity"] == "error" }
65
67
  result(ok, state_path, workflow_path, registry_path, issues)
@@ -96,16 +98,19 @@ module WorkflowStateValidator
96
98
  nil
97
99
  end
98
100
 
99
- def load_artifacts(root, registry_path, issues, state_revision:, strict_registry_revision:)
101
+ def load_artifacts(root, registry_path, issues, state_revision:, strict_registry_revision:, registry_override: nil)
100
102
  return {} unless registry_path
101
103
 
102
- abs_registry_path = File.expand_path(registry_path, root)
103
- unless File.exist?(abs_registry_path)
104
- issues << issue("error", "missing_artifact_registry", "Artifact registry does not exist", path: registry_path)
105
- return {}
106
- end
104
+ registry = registry_override
105
+ unless registry
106
+ abs_registry_path = File.expand_path(registry_path, root)
107
+ unless File.exist?(abs_registry_path)
108
+ issues << issue("error", "missing_artifact_registry", "Artifact registry does not exist", path: registry_path)
109
+ return {}
110
+ end
107
111
 
108
- registry = YAML.safe_load(File.read(abs_registry_path), permitted_classes: [], aliases: false)
112
+ registry = YAML.safe_load(File.read(abs_registry_path), permitted_classes: [], aliases: false)
113
+ end
109
114
  if strict_registry_revision && registry.key?("state_revision") && Integer(registry["state_revision"]) != Integer(state_revision)
110
115
  issues << issue(
111
116
  "error",
@@ -207,6 +212,37 @@ module WorkflowStateValidator
207
212
  validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, true)
208
213
  end
209
214
 
215
+ # An in-progress step may have written its output before its complete-step
216
+ # transition. Treat a stale registry observation as recoverable drift, not as
217
+ # proof that the output is absent. Completion will reconcile it atomically.
218
+ def validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
219
+ return unless state["status"] == "in_progress"
220
+
221
+ step = step_by_id[state["current_step"]]
222
+ return unless step
223
+
224
+ Array(step["outputs"]).each do |output|
225
+ next unless output["required"]
226
+
227
+ path = interpolate_path(output.fetch("path"), state)
228
+ next unless File.exist?(File.expand_path(path, root))
229
+
230
+ artifact = artifact_by_path[path]
231
+ next if artifact && artifact["exists"] != false && artifact["content_valid"] != false
232
+
233
+ issues << issue(
234
+ "warning",
235
+ "current_output_registry_drift",
236
+ "Current in-progress step output exists but artifact registry is missing or marks it invalid",
237
+ step: step["id"],
238
+ path: path,
239
+ registry_exists: artifact && artifact["exists"],
240
+ registry_content_valid: artifact && artifact["content_valid"],
241
+ recovery: "Call devflow_artifact with action=sync and preserve=false, then retry the complete-step transition."
242
+ )
243
+ end
244
+ end
245
+
210
246
  def validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, waiting_checkpoint)
211
247
  Array(step["outputs"]).each do |output|
212
248
  next unless output["required"]