@guanlin4924/devflow-pi 0.1.101 → 0.1.102

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.101
14
+ npm install -g @guanlin4924/devflow-pi@0.1.102
15
15
  cd /path/to/your-project
16
16
  devflow-pi init
17
17
  devflow-pi doctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.101",
3
+ "version": "0.1.102",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -5,6 +5,8 @@ description: Generate or revise a low-fidelity wireframe proposal from controlle
5
5
 
6
6
  # Wireframe Proposal
7
7
 
8
+ Run only when Core reports `wireframe-proposal` as the current step. A no-UI decision is evaluated and recorded by Core after CP-1; do not infer it from requirement prose, edit workflow state, or self-skip this step.
9
+
8
10
  Read the controlled requirement baseline and the wireframe schema/registry. Produce exactly two candidate files:
9
11
 
10
12
  - .devflow/planning/features/{feature}/proposal.yaml
@@ -5,4 +5,6 @@ description: Render validated canonical wireframe JSON into deterministic Markdo
5
5
 
6
6
  # Wireframe Render
7
7
 
8
+ Run only when Core reports `wireframe-render` as the current step. A no-UI decision is evaluated and recorded by Core after CP-1; do not infer it from requirement prose, edit workflow state, or self-skip this step.
9
+
8
10
  Call the workflow-orchestrator wireframe_render.py script only with the current Core-built canonical JSON and the validation metadata. Generate preview Markdown, flow SVG, one SVG per declared state under `wireframes/screens/<screen-id>/<platform>/<state>.svg` (`default.svg` is always present), and the optional static gallery under the wireframe output directory. The `wireframes/screens` directory is a required render output and is covered by the execution contract. The `canonical.meta.json` file is created later by the Core approval path and is not an input to this step. Never read proposal YAML as a substitute, mutate canonical JSON, write approval, or modify workflow state.
@@ -5,4 +5,6 @@ description: Present validated wireframe previews and unresolved questions for e
5
5
 
6
6
  # Wireframe Review
7
7
 
8
+ Run only when Core reports `wireframe-review` as the current step. A no-UI decision is evaluated and recorded by Core after CP-1; do not infer it from requirement prose, edit workflow state, or self-skip this step.
9
+
8
10
  Show the current canonical-derived previews, renderer metadata, validation verdict, and unresolved question IDs. If the verdict is await_user, ask the user to answer the listed blocking questions. If the user approves, return control to the Core checkpoint path. Never write approval, pass runtime confirmation, confirm a checkpoint, or alter canonical/state yourself.
@@ -5,6 +5,8 @@ description: Run deterministic validation for a wireframe proposal and build can
5
5
 
6
6
  # Wireframe Validate
7
7
 
8
+ Run only when Core reports `wireframe-validate` as the current step. A no-UI decision is evaluated and recorded by Core after CP-1; do not infer it from requirement prose, edit workflow state, or self-skip this step.
9
+
8
10
  Run the bundled validator through Core's `devflow_skill_script` tool:
9
11
 
10
12
  ```text
@@ -206,15 +206,18 @@ def build_artifact_registry(root, workflow, state)
206
206
  end
207
207
 
208
208
  completed = Array(state["completed_steps"])
209
+ skipped = state["skipped_steps"].is_a?(Hash) ? state["skipped_steps"] : {}
209
210
  current_step = state["current_step"]
210
211
  content_checks = workflow.dig("artifact_registry", "content_checks") || {}
211
- preservable_statuses = %w[approved completed passed pending_rerun stale failed]
212
+ preservable_statuses = %w[approved completed passed skipped pending_rerun stale failed]
212
213
 
213
214
  artifacts = collect_outputs(workflow).map do |step, output|
214
215
  path = interpolate_path(output.fetch("path"), state)
215
216
  existing = existing_by_path[path] || {}
216
217
  generated_status =
217
- if completed.include?(step.fetch("id"))
218
+ if skipped.key?(step.fetch("id"))
219
+ "skipped"
220
+ elsif completed.include?(step.fetch("id"))
218
221
  step["checkpoint"] ? "approved" : "completed"
219
222
  elsif step.fetch("id") == current_step && state["status"] == "waiting_checkpoint"
220
223
  "generated"
@@ -222,7 +225,8 @@ def build_artifact_registry(root, workflow, state)
222
225
  "planned"
223
226
  end
224
227
 
225
- validity = content_validity(root, path, content_checks)
228
+ skip_record = skipped[step.fetch("id")]
229
+ validity = skip_record ? { "valid" => true, "issues" => [] } : content_validity(root, path, content_checks)
226
230
  artifact_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
227
231
  file_exists = File.exist?(artifact_path)
228
232
  status = if preservable_statuses.include?(existing["status"])
@@ -242,7 +246,8 @@ def build_artifact_registry(root, workflow, state)
242
246
  "downstream_steps" => Array(existing["downstream_steps"]),
243
247
  "exists" => file_exists,
244
248
  "content_valid" => validity["valid"],
245
- "content_issues" => validity["issues"].empty? ? nil : validity["issues"]
249
+ "content_issues" => validity["issues"].empty? ? nil : validity["issues"],
250
+ "skip_reason" => skip_record && skip_record["reason"]
246
251
  }.reject { |_, value| value.nil? || value == [] }
247
252
  end
248
253
 
@@ -429,6 +434,60 @@ def next_pending_step_id(state, pending_ids, step_by_id, completed_ids)
429
434
  end
430
435
  end
431
436
 
437
+ def conditional_skip_policies(workflow, checkpoint_id)
438
+ policies = workflow.dig("governance", "conditional_step_skips")
439
+ return [] unless policies.is_a?(Hash)
440
+
441
+ policies.each_with_object([]) do |(policy_id, policy), matches|
442
+ next unless policy.is_a?(Hash) && policy["trigger_checkpoint"].to_s == checkpoint_id.to_s
443
+
444
+ matches << [policy_id.to_s, policy]
445
+ end
446
+ end
447
+
448
+ # Conditional skips are Core-owned transitions. The baseline skill can only
449
+ # submit the typed metadata fact; it cannot edit STATE or omit workflow steps.
450
+ def apply_conditional_step_skips!(root, state, workflow, checkpoint_id, next_revision)
451
+ applied = []
452
+ conditional_skip_policies(workflow, checkpoint_id).each do |policy_id, policy|
453
+ source_path = interpolate_path(policy.fetch("source_path"), state)
454
+ source_abs, = DevflowControlIO.resolve_control_path(root, source_path, :artifact)
455
+ next unless File.file?(source_abs)
456
+
457
+ source = JSON.parse(File.read(source_abs))
458
+ source_field = policy.fetch("source_field").to_s
459
+ next unless source.key?(source_field) && source[source_field] == policy["equals"]
460
+
461
+ step_ids = Array(policy.fetch("steps")).map(&:to_s)
462
+ completed = Array(state["completed_steps"]).map(&:to_s)
463
+ pending = Array(state["pending_steps"]).map(&:to_s)
464
+ conflicts = step_ids.reject { |step_id| pending.include?(step_id) && !completed.include?(step_id) }
465
+ unless conflicts.empty?
466
+ raise ArgumentError, "conditional skip #{policy_id} requires every target step to be pending: #{conflicts.join(', ')}"
467
+ end
468
+
469
+ evidence = {
470
+ "status" => "skipped",
471
+ "policy" => policy_id,
472
+ "reason" => policy.fetch("reason").to_s,
473
+ "source" => source_path,
474
+ "source_field" => source_field,
475
+ "source_value" => source[source_field],
476
+ "source_sha256" => Digest::SHA256.file(source_abs).hexdigest,
477
+ "recorded_at_revision" => next_revision
478
+ }
479
+ state["skipped_steps"] = {} unless state["skipped_steps"].is_a?(Hash)
480
+ step_ids.each { |step_id| state["skipped_steps"][step_id] = evidence.dup }
481
+ checkpoint = policy.fetch("checkpoint").to_s
482
+ state["skipped_checkpoints"] = {} unless state["skipped_checkpoints"].is_a?(Hash)
483
+ state["skipped_checkpoints"][checkpoint] = evidence.merge("steps" => step_ids)
484
+ state["completed_steps"] = (completed + step_ids).uniq
485
+ state["pending_steps"] = pending - step_ids
486
+ applied << { "policy" => policy_id, "steps" => step_ids, "checkpoint" => checkpoint, "reason" => evidence["reason"] }
487
+ end
488
+ applied
489
+ end
490
+
432
491
  def track_boundary?(state, pending_ids)
433
492
  track = active_track(state)
434
493
  return false unless track
@@ -555,6 +614,7 @@ HARD_FAILURE_REASONS = %w[
555
614
  parallel_machine_evidence_failed
556
615
  parallel_join_failed
557
616
  parallel_branch_transition_failed
617
+ conditional_step_skip_failed
558
618
  cross_track_consistency_failed
559
619
  business_cross_model_check_failed
560
620
  gate_boundary_rejected
@@ -1238,30 +1298,41 @@ result =
1238
1298
  end
1239
1299
  end
1240
1300
  if result.nil?
1241
- pending = Array(state["pending_steps"])
1242
- completed = Array(state["completed_steps"])
1243
- next_id = next_pending_step_id(state, pending, step_by_id, completed)
1244
- if next_id.nil? && track_boundary?(state, pending)
1245
- state["checkpoint_decision"] = "approved"
1246
- state["status"] = "blocked"
1247
- state["block_reason"] = "track_scope_complete"
1248
- state.delete("checkpoint")
1249
- state.delete("checkpoint_label")
1250
- changed = true
1251
- actions << "checkpoint_confirmed_track_complete"
1252
- action_description = "Checkpoint confirmed. Active track output scope is complete."
1253
- next_intent_requested = true
1254
- else
1255
- state["checkpoint_decision"] = "approved"
1256
- state["current_step"] = next_id || pending.first
1257
- state["status"] = "in_progress"
1258
- state.delete("checkpoint")
1259
- state.delete("checkpoint_label")
1260
- changed = true
1261
- actions << "checkpoint_confirmed"
1262
- action_description = "Checkpoint confirmed. Advanced to next step."
1263
- next_intent_requested = true
1301
+ begin
1302
+ applied_skips = apply_conditional_step_skips!(root, state, workflow, state["checkpoint"], current_revision + 1)
1303
+ unless applied_skips.empty?
1304
+ actions << "conditional_steps_skipped"
1305
+ actions.concat(applied_skips.flat_map { |entry| entry["steps"].map { |step_id| "step_skipped:#{step_id}" } })
1306
+ end
1307
+ rescue JSON::ParserError, KeyError, ArgumentError, DevflowControlIO::InvalidControlPath => error
1308
+ result = fail_result("conditional_step_skip_failed", "message" => error.message, "checkpoint" => state["checkpoint"])
1309
+ end
1264
1310
  end
1311
+ if result.nil?
1312
+ pending = Array(state["pending_steps"])
1313
+ completed = Array(state["completed_steps"])
1314
+ next_id = next_pending_step_id(state, pending, step_by_id, completed)
1315
+ if next_id.nil? && track_boundary?(state, pending)
1316
+ state["checkpoint_decision"] = "approved"
1317
+ state["status"] = "blocked"
1318
+ state["block_reason"] = "track_scope_complete"
1319
+ state.delete("checkpoint")
1320
+ state.delete("checkpoint_label")
1321
+ changed = true
1322
+ actions << "checkpoint_confirmed_track_complete"
1323
+ action_description = "Checkpoint confirmed. Active track output scope is complete."
1324
+ next_intent_requested = true
1325
+ else
1326
+ state["checkpoint_decision"] = "approved"
1327
+ state["current_step"] = next_id || pending.first
1328
+ state["status"] = "in_progress"
1329
+ state.delete("checkpoint")
1330
+ state.delete("checkpoint_label")
1331
+ changed = true
1332
+ actions << "checkpoint_confirmed"
1333
+ action_description = "Checkpoint confirmed. Advanced to next step."
1334
+ next_intent_requested = true
1335
+ end
1265
1336
  end
1266
1337
  end
1267
1338
  end
@@ -4,8 +4,20 @@
4
4
  require "json"
5
5
 
6
6
  module CheckpointMeta
7
+ LEGACY_APPROVAL_OUTPUTS = {
8
+ "CP-WIREFRAME" => [".devflow/planning/features/{feature}/canonical.meta.json"]
9
+ }.freeze
10
+
7
11
  module_function
8
12
 
13
+ def approval_output?(checkpoint, path)
14
+ return false unless checkpoint.is_a?(Hash)
15
+
16
+ declared = Array(checkpoint["approval_outputs"]).map(&:to_s)
17
+ legacy = LEGACY_APPROVAL_OUTPUTS.fetch(checkpoint["id"].to_s, [])
18
+ (declared + legacy).include?(path.to_s)
19
+ end
20
+
9
21
  def validate(root:, state:, checkpoint:)
10
22
  return [] unless checkpoint.is_a?(Hash)
11
23
 
@@ -35,7 +35,9 @@ module GateRuntime
35
35
  # During the chain-skill migration Core is diagnostic-only. It still
36
36
  # validates the contract shape above, but never blocks a transition on
37
37
  # graph, artifact, output, checker, review, or topology observations.
38
- if contract.fetch("hard_gate_mode", "core").to_s == "chain"
38
+ # Checkpoint confirmation remains fail-closed because approval-path outputs
39
+ # do not exist until the user approves the waiting checkpoint.
40
+ if contract.fetch("hard_gate_mode", "core").to_s == "chain" && transition_kind.to_s != "confirm_checkpoint"
39
41
  return GateResult.allow(**base.merge(evidence: base.fetch(:evidence).merge("core_hard_gate_mode" => "chain", "core_hard_gate_bypassed" => true)))
40
42
  end
41
43
  return GateResult.block(**base, code: "REVISION_CONFLICT", message: "current revision does not match expected revision", recovery: { "kind" => "reinspect", "expected_revision" => expected_revision }) unless expected_revision.nil? || current_revision.to_i == expected_revision.to_i
@@ -79,9 +81,12 @@ module GateRuntime
79
81
  return GateResult.block(**base, code: "REQUIRED_OUTPUT_MISSING", message: "required step output is missing", details: { "missing" => missing_outputs }) unless missing_outputs.empty?
80
82
  checkpoint = contract.dig("topology", "checkpoint")
81
83
  if checkpoint.is_a?(Hash) && checkpoint["producing_step"].to_s == step_id.to_s
82
- meta_errors = CheckpointMeta.validate(root: root, state: workflow_state, checkpoint: checkpoint)
83
- unless meta_errors.empty?
84
- return GateResult.block(**base, code: "CHECKPOINT_META_INVALID", message: "checkpoint meta output is missing or invalid", details: { "checkpoint" => checkpoint["id"], "errors" => meta_errors })
84
+ meta_path = checkpoint["required_meta_output"]
85
+ unless CheckpointMeta.approval_output?(checkpoint, meta_path)
86
+ meta_errors = CheckpointMeta.validate(root: root, state: workflow_state, checkpoint: checkpoint)
87
+ unless meta_errors.empty?
88
+ return GateResult.block(**base, code: "CHECKPOINT_META_INVALID", message: "checkpoint meta output is missing or invalid", details: { "checkpoint" => checkpoint["id"], "errors" => meta_errors })
89
+ end
85
90
  end
86
91
  end
87
92
  invalid_outputs = validate_declared_inputs(contract.dig("outputs", "required_inputs"), root, workflow_state, purpose: :completion)
@@ -111,12 +116,16 @@ module GateRuntime
111
116
  checkpoint = contract.dig("topology", "checkpoint")
112
117
  checkpoint_matches = checkpoint.is_a?(Hash) && checkpoint["id"].to_s == checkpoint_id
113
118
  unless workflow_state["status"].to_s == "waiting_checkpoint" && checkpoint_matches && checkpoint_authorized
114
- return GateResult.block(**base, code: "CHECKPOINT_NOT_AUTHORIZED", message: "checkpoint confirmation is not authorized by the runtime", details: { "checkpoint" => checkpoint_id })
119
+ return GateResult.block(**base, code: "CHECKPOINT_NOT_AUTHORIZED", message: "checkpoint confirmation is not authorized by the runtime", details: { "checkpoint" => checkpoint_id })
115
120
  end
116
121
  unless workflow_state["completed_steps"].to_a.include?(checkpoint["producing_step"].to_s)
117
122
  return GateResult.block(**base, code: "CHECKPOINT_PRODUCER_INCOMPLETE", message: "checkpoint producing step is not completed", details: { "checkpoint" => checkpoint_id })
118
123
  end
119
124
  if root
125
+ missing_outputs = required_paths(checkpoint["required_outputs"], root, workflow_state)
126
+ unless missing_outputs.empty?
127
+ return GateResult.block(**base, code: "CHECKPOINT_OUTPUT_MISSING", message: "checkpoint required output is missing", details: { "checkpoint" => checkpoint_id, "missing" => missing_outputs })
128
+ end
120
129
  meta_errors = CheckpointMeta.validate(root: root, state: workflow_state, checkpoint: checkpoint)
121
130
  unless meta_errors.empty?
122
131
  return GateResult.block(**base, code: "CHECKPOINT_META_INVALID", message: "checkpoint meta output is missing or invalid", details: { "checkpoint" => checkpoint_id, "errors" => meta_errors })
@@ -141,13 +141,16 @@ end
141
141
  # authority for that distinction.
142
142
  def workflow_steps_payload(steps, state, result)
143
143
  completed_ids = Array(state["completed_steps"])
144
+ skipped_ids = state["skipped_steps"].is_a?(Hash) ? state["skipped_steps"].keys : []
144
145
  pending_ids = Array(state["pending_steps"])
145
146
  current_id = state["current_step"]
146
147
  action = result[:action] || result["action"]
147
148
 
148
149
  Array(steps).map do |step|
149
150
  id = step["id"]
150
- status = if completed_ids.include?(id)
151
+ status = if skipped_ids.include?(id)
152
+ "skipped"
153
+ elsif completed_ids.include?(id)
151
154
  "completed"
152
155
  elsif id == current_id && action == "await_user_confirmation"
153
156
  "waiting_review"
@@ -85,7 +85,7 @@ def independent_evidence(root, feature, step, state, workflow, graph, files, che
85
85
 
86
86
  {
87
87
  "collector" => "core.machine_evidence/v1",
88
- "state" => { "revision" => state["revision"], "step" => step },
88
+ "state" => { "revision" => state["revision"], "step" => step, "skipped_steps" => state["skipped_steps"] },
89
89
  "parallel_binding" => binding,
90
90
  "input_fingerprints" => inputs.select { |item| item["exists"] }.map { |item| item["sha256"] },
91
91
  "output_fingerprints" => files.select { |item| item["exists"] }.map { |item| item["sha256"] },
@@ -42,9 +42,18 @@ def build_registry(root, workflow, state)
42
42
  issues << "missing_required_header(#{checks["required_header"].inspect})" unless head.include?(checks["required_header"])
43
43
  end
44
44
  completed = Array(state["completed_steps"]).include?(step.fetch("id"))
45
- status = completed ? (step["checkpoint"] ? "approved" : "completed") : "planned"
45
+ skip_record = state["skipped_steps"].is_a?(Hash) && state["skipped_steps"][step.fetch("id")]
46
+ status = if skip_record
47
+ "skipped"
48
+ elsif completed
49
+ step["checkpoint"] ? "approved" : "completed"
50
+ else
51
+ "planned"
52
+ end
53
+ issues = [] if skip_record
46
54
  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? }
47
55
  entry["checkpoint"] = step["checkpoint"] if step["checkpoint"]
56
+ entry["skip_reason"] = skip_record["reason"] if skip_record
48
57
  entry["content_issues"] = issues unless issues.empty?
49
58
  entry
50
59
  end
@@ -27,7 +27,7 @@ module WorkflowStateValidator
27
27
  pending_steps
28
28
  ].freeze
29
29
 
30
- TERMINAL_ARTIFACT_STATUSES = %w[approved completed passed].freeze
30
+ TERMINAL_ARTIFACT_STATUSES = %w[approved completed passed skipped].freeze
31
31
  NON_TERMINAL_ARTIFACT_STATUSES = %w[planned generated pending_rerun stale failed].freeze
32
32
  TERMINAL_WORKFLOW_STATUSES = %w[completed failed cancelled].freeze
33
33
  # Core quality checks are advisory by default. Keep only structural and
@@ -70,6 +70,7 @@ module WorkflowStateValidator
70
70
  return true if value.start_with?("duplicate_", "unknown_", "missing_tracked_step")
71
71
  return true if value.start_with?("step_in_", "completed_", "terminal_", "non_completed_")
72
72
  return true if value.start_with?("waiting_checkpoint_")
73
+ return true if value.start_with?("skipped_")
73
74
  return true if %w[missing_required_output output_not_registered].include?(value)
74
75
 
75
76
  false
@@ -127,6 +128,7 @@ module WorkflowStateValidator
127
128
  tracked_ids = completed_ids + pending_ids
128
129
 
129
130
  validate_step_tracking(issues, state, step_ids, completed_ids, pending_ids, tracked_ids)
131
+ validate_skipped_transitions(issues, root, state, workflow, step_by_id, completed_ids, pending_ids)
130
132
  if state["parallel_run_ref"]
131
133
  validate_parallel_run(issues, root, state, workflow, step_by_id)
132
134
  else
@@ -335,6 +337,91 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
335
337
  end
336
338
  end
337
339
 
340
+ def validate_skipped_transitions(issues, root, state, workflow, step_by_id, completed_ids, pending_ids)
341
+ skipped_steps = state["skipped_steps"]
342
+ skipped_checkpoints = state["skipped_checkpoints"]
343
+ if skipped_steps && !skipped_steps.is_a?(Hash)
344
+ issues << issue("error", "skipped_steps_invalid", "STATE.skipped_steps must be a mapping")
345
+ return
346
+ end
347
+ if skipped_checkpoints && !skipped_checkpoints.is_a?(Hash)
348
+ issues << issue("error", "skipped_checkpoints_invalid", "STATE.skipped_checkpoints must be a mapping")
349
+ return
350
+ end
351
+ return if skipped_steps.nil? && skipped_checkpoints.nil?
352
+
353
+ policies = workflow.dig("governance", "conditional_step_skips")
354
+ policies = {} unless policies.is_a?(Hash)
355
+ (skipped_steps || {}).each do |step_id, record|
356
+ unless record.is_a?(Hash)
357
+ issues << issue("error", "skipped_step_record_invalid", "Skipped step record must be a mapping", step: step_id)
358
+ next
359
+ end
360
+ policy_id = record["policy"].to_s
361
+ policy = policies[policy_id]
362
+ unless policy.is_a?(Hash) && Array(policy["steps"]).map(&:to_s).include?(step_id.to_s)
363
+ issues << issue("error", "skipped_step_policy_invalid", "Skipped step is not authorized by the workflow policy", step: step_id, policy: policy_id)
364
+ next
365
+ end
366
+ issues << issue("error", "skipped_step_unknown", "Skipped step is not defined in the workflow", step: step_id) unless step_by_id.key?(step_id.to_s)
367
+ issues << issue("error", "skipped_step_not_completed", "Skipped step must be tracked as completed", step: step_id) unless completed_ids.include?(step_id)
368
+ issues << issue("error", "skipped_step_still_pending", "Skipped step cannot remain pending", step: step_id) if pending_ids.include?(step_id)
369
+ issues << issue("error", "skipped_step_status_invalid", "Skipped step record status must be skipped", step: step_id) unless record["status"] == "skipped"
370
+ issues << issue("error", "skipped_step_reason_invalid", "Skipped step reason does not match its policy", step: step_id) unless record["reason"].to_s == policy["reason"].to_s
371
+ recorded_revision = record["recorded_at_revision"]
372
+ unless recorded_revision.is_a?(Integer) && recorded_revision.positive? && recorded_revision <= state.fetch("revision", 0).to_i
373
+ issues << issue("error", "skipped_step_revision_invalid", "Skipped step decision revision is invalid", step: step_id, recorded_at_revision: recorded_revision)
374
+ end
375
+ validate_skip_source(issues, root, state, step_id, record, policy)
376
+ end
377
+
378
+ (skipped_checkpoints || {}).each do |checkpoint_id, record|
379
+ unless record.is_a?(Hash)
380
+ issues << issue("error", "skipped_checkpoint_record_invalid", "Skipped checkpoint record must be a mapping", checkpoint: checkpoint_id)
381
+ next
382
+ end
383
+ policy = policies[record["policy"].to_s]
384
+ unless policy.is_a?(Hash) && policy["checkpoint"].to_s == checkpoint_id.to_s
385
+ issues << issue("error", "skipped_checkpoint_policy_invalid", "Skipped checkpoint is not authorized by the workflow policy", checkpoint: checkpoint_id)
386
+ next
387
+ end
388
+ expected_steps = Array(policy["steps"]).map(&:to_s)
389
+ unless Array(record["steps"]).map(&:to_s) == expected_steps && expected_steps.all? { |step_id| skipped_steps.is_a?(Hash) && skipped_steps.key?(step_id) }
390
+ issues << issue("error", "skipped_checkpoint_steps_invalid", "Skipped checkpoint must reference every policy step", checkpoint: checkpoint_id)
391
+ end
392
+ end
393
+
394
+ applied_policies = (skipped_steps || {}).values.each_with_object([]) do |record, matches|
395
+ policy = record.is_a?(Hash) && policies[record["policy"].to_s]
396
+ matches << policy if policy
397
+ end
398
+ applied_policies.uniq.each do |policy|
399
+ checkpoint_id = policy["checkpoint"].to_s
400
+ unless skipped_checkpoints.is_a?(Hash) && skipped_checkpoints.key?(checkpoint_id)
401
+ issues << issue("error", "skipped_checkpoint_missing", "Conditional step skip must record its skipped checkpoint", checkpoint: checkpoint_id)
402
+ end
403
+ end
404
+ end
405
+
406
+ def validate_skip_source(issues, root, state, step_id, record, policy)
407
+ expected_path = interpolate_path(policy.fetch("source_path"), state)
408
+ unless record["source"].to_s == expected_path && record["source_field"].to_s == policy["source_field"].to_s && record["source_value"] == policy["equals"]
409
+ issues << issue("error", "skipped_step_source_binding_invalid", "Skipped step source binding does not match its policy", step: step_id)
410
+ return
411
+ end
412
+ source_abs, = DevflowControlIO.resolve_control_path(root, expected_path, :artifact)
413
+ source = JSON.parse(File.read(source_abs))
414
+ source_field = policy.fetch("source_field").to_s
415
+ unless source.key?(source_field) && source[source_field] == policy["equals"]
416
+ issues << issue("error", "skipped_step_source_value_invalid", "Skip source no longer authorizes the skipped step", step: step_id, path: expected_path)
417
+ end
418
+ unless record["source_sha256"].to_s == Digest::SHA256.file(source_abs).hexdigest
419
+ issues << issue("error", "skipped_step_source_fingerprint_invalid", "Skip source fingerprint does not match the recorded decision", step: step_id, path: expected_path)
420
+ end
421
+ rescue KeyError, JSON::ParserError, Errno::ENOENT, DevflowControlIO::InvalidControlPath => error
422
+ issues << issue("error", "skipped_step_source_invalid", "Skip source cannot be verified", step: step_id, message: error.message)
423
+ end
424
+
338
425
  def validate_parallel_run(issues, root, state, workflow, step_by_id)
339
426
  feature = state["current_feature"].to_s
340
427
  run = ParallelRun.load(root, feature)
@@ -447,7 +534,10 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
447
534
  end
448
535
 
449
536
  def validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
537
+ skipped_ids = state["skipped_steps"].is_a?(Hash) ? state["skipped_steps"].keys : []
450
538
  completed_ids.each do |step_id|
539
+ next if skipped_ids.include?(step_id)
540
+
451
541
  step = step_by_id[step_id]
452
542
  next unless step
453
543
 
@@ -472,8 +562,14 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
472
562
  validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, true)
473
563
  checkpoint_id = state["checkpoint"] || step["checkpoint"]
474
564
  checkpoint = workflow.fetch("checkpoints", {})[checkpoint_id]
475
- CheckpointMeta.validate(root: root, state: state, checkpoint: checkpoint).each do |entry|
476
- issues << issue("error", entry.fetch("code"), entry.fetch("message"), step: step["id"], checkpoint: checkpoint_id, **entry.reject { |key, _| %w[code message].include?(key) })
565
+ checkpoint = checkpoint.merge("id" => checkpoint_id) if checkpoint.is_a?(Hash)
566
+ meta_path = checkpoint.is_a?(Hash) && checkpoint["required_meta_output"]
567
+ unless CheckpointMeta.approval_output?(checkpoint, meta_path)
568
+ CheckpointMeta.validate(root: root, state: state, checkpoint: checkpoint).each do |entry|
569
+ details = { step: step["id"], checkpoint: checkpoint_id }
570
+ .merge(entry.reject { |key, _| %w[code message].include?(key) })
571
+ issues << issue("error", entry.fetch("code"), entry.fetch("message"), details)
572
+ end
477
573
  end
478
574
  end
479
575
 
@@ -60,6 +60,14 @@ module WorkflowGateContract
60
60
  end
61
61
  errors << error("CHECKPOINT_PRODUCER_MISMATCH", "gate_after must equal producing_step", checkpoint: id) if checkpoint["gate_after"].to_s != checkpoint["producing_step"].to_s
62
62
  errors << error("CHECKPOINT_OUTPUTS_INVALID", "required_outputs must be an array", checkpoint: id) unless checkpoint["required_outputs"].is_a?(Array)
63
+ if checkpoint.key?("approval_outputs")
64
+ approval_outputs = checkpoint["approval_outputs"]
65
+ errors << error("CHECKPOINT_APPROVAL_OUTPUTS_INVALID", "approval_outputs must be an array", checkpoint: id) unless approval_outputs.is_a?(Array)
66
+ if approval_outputs.is_a?(Array) && checkpoint["required_outputs"].is_a?(Array)
67
+ undeclared = approval_outputs.map(&:to_s) - checkpoint["required_outputs"].map(&:to_s)
68
+ errors << error("CHECKPOINT_APPROVAL_OUTPUT_UNDECLARED", "approval_outputs must also be required_outputs", checkpoint: id, outputs: undeclared) unless undeclared.empty?
69
+ end
70
+ end
63
71
  errors << error("CHECKPOINT_META_FIELDS_INVALID", "required_meta_fields must be an array", checkpoint: id) unless checkpoint["required_meta_fields"].is_a?(Array)
64
72
  errors << error("CHECKPOINT_OPTIONS_INVALID", "confirm_options must include approve and needs_fix", checkpoint: id) unless Array(checkpoint["confirm_options"]).sort == %w[approve needs_fix]
65
73
  end
@@ -68,6 +76,41 @@ module WorkflowGateContract
68
76
  errors << error("CHECKPOINT_REF_NOT_FOUND", "enforced checkpoint is not defined", checkpoint: id) unless checkpoints.key?(id)
69
77
  end
70
78
 
79
+ conditional_skips = workflow.dig("governance", "conditional_step_skips")
80
+ if conditional_skips && !conditional_skips.is_a?(Hash)
81
+ errors << error("CONDITIONAL_STEP_SKIPS_SCHEMA_INVALID", "conditional_step_skips must be a mapping")
82
+ end
83
+ (conditional_skips.is_a?(Hash) ? conditional_skips : {}).each do |policy_id, policy|
84
+ unless policy.is_a?(Hash)
85
+ errors << error("CONDITIONAL_STEP_SKIP_POLICY_INVALID", "conditional step skip policy must be a mapping", policy: policy_id)
86
+ next
87
+ end
88
+ %w[trigger_checkpoint source_path source_field equals reason steps checkpoint].each do |field|
89
+ errors << error("CONDITIONAL_STEP_SKIP_FIELD_MISSING", "conditional step skip field is required", policy: policy_id, field: field) unless policy.key?(field)
90
+ end
91
+ errors << error("CONDITIONAL_STEP_SKIP_TRIGGER_UNKNOWN", "conditional step skip trigger checkpoint is not defined", policy: policy_id, checkpoint: policy["trigger_checkpoint"]) unless checkpoints.key?(policy["trigger_checkpoint"])
92
+ skipped_checkpoint = checkpoints[policy["checkpoint"]]
93
+ if skipped_checkpoint.nil?
94
+ errors << error("CONDITIONAL_STEP_SKIP_CHECKPOINT_UNKNOWN", "conditional step skip checkpoint is not defined", policy: policy_id, checkpoint: policy["checkpoint"])
95
+ elsif skipped_checkpoint["skip_policy"].to_s != policy_id.to_s
96
+ errors << error("CONDITIONAL_STEP_SKIP_CHECKPOINT_MISMATCH", "skipped checkpoint must declare the same skip policy", policy: policy_id, checkpoint: policy["checkpoint"])
97
+ end
98
+ unless policy["equals"] == true || policy["equals"] == false
99
+ errors << error("CONDITIONAL_STEP_SKIP_VALUE_INVALID", "conditional step skip equals value must be boolean", policy: policy_id)
100
+ end
101
+ unless policy["steps"].is_a?(Array) && policy["steps"].any?
102
+ errors << error("CONDITIONAL_STEP_SKIP_STEPS_INVALID", "conditional step skip steps must be a non-empty array", policy: policy_id)
103
+ end
104
+ Array(policy["steps"]).each do |step_id|
105
+ step = step_by_id[step_id.to_s]
106
+ if step.nil?
107
+ errors << error("CONDITIONAL_STEP_SKIP_STEP_UNKNOWN", "conditional step skip references an unknown step", policy: policy_id, step: step_id)
108
+ elsif step["skip_policy"].to_s != policy_id.to_s
109
+ errors << error("CONDITIONAL_STEP_SKIP_STEP_MISMATCH", "skipped step must declare the same skip policy", policy: policy_id, step: step_id)
110
+ end
111
+ end
112
+ end
113
+
71
114
  step_checkers = workflow.dig("governance", "step_checkers")
72
115
  if step_checkers.is_a?(Hash)
73
116
  step_checkers.each do |step_id, checker_ids|
@@ -29,6 +29,19 @@ governance:
29
29
  - CP-2
30
30
  - CP-3
31
31
  - CP-4
32
+ conditional_step_skips:
33
+ no_ui_change:
34
+ trigger_checkpoint: CP-1
35
+ source_path: ".devflow/planning/requirements/baseline.meta.json"
36
+ source_field: ui_change_required
37
+ equals: false
38
+ reason: no_ui_change
39
+ steps:
40
+ - wireframe-proposal
41
+ - wireframe-validate
42
+ - wireframe-render
43
+ - wireframe-review
44
+ checkpoint: CP-WIREFRAME
32
45
  step_checkers:
33
46
  requirement-baseline:
34
47
  - requirement-graph-check
@@ -234,6 +247,10 @@ checkpoints:
234
247
  - ".devflow/planning/features/{feature}/wireframes/preview.md"
235
248
  - ".devflow/planning/features/{feature}/wireframes/flow.svg"
236
249
  - ".devflow/planning/features/{feature}/wireframes/screens"
250
+ # Core writes this only after the user approves the waiting checkpoint.
251
+ # Entry validation defers it; checkpoint confirmation still requires it.
252
+ approval_outputs:
253
+ - ".devflow/planning/features/{feature}/canonical.meta.json"
237
254
  required_meta_output: ".devflow/planning/features/{feature}/canonical.meta.json"
238
255
  required_meta_fields:
239
256
  - prototype_fingerprint
@@ -247,7 +264,7 @@ checkpoints:
247
264
  confirm_options:
248
265
  - approve
249
266
  - needs_fix
250
- skip_policy: never
267
+ skip_policy: no_ui_change
251
268
  resume_behavior: "user confirms, then continue with api-contract"
252
269
  CP-API:
253
270
  name: API contract confirmation
@@ -520,6 +537,7 @@ steps:
520
537
  risks: { type: array, required: false }
521
538
  key_decisions: { type: array, max: 5, required: false }
522
539
  traceability_status: { type: string, required: true }
540
+ ui_change_required: { type: boolean, required: false }
523
541
  checkpoint: CP-1
524
542
 
525
543
  - id: wireframe-proposal
@@ -527,6 +545,7 @@ steps:
527
545
  label: Wireframe proposal
528
546
  skill: wireframe-proposal
529
547
  engine_evidence_gate: enforced
548
+ skip_policy: no_ui_change
530
549
  depends_on:
531
550
  - requirement-baseline
532
551
  inputs:
@@ -548,6 +567,7 @@ steps:
548
567
  label: Validate wireframe proposal
549
568
  skill: wireframe-validate
550
569
  engine_evidence_gate: enforced
570
+ skip_policy: no_ui_change
551
571
  depends_on:
552
572
  - wireframe-proposal
553
573
  inputs:
@@ -572,6 +592,7 @@ steps:
572
592
  label: Render wireframe previews
573
593
  skill: wireframe-render
574
594
  engine_evidence_gate: enforced
595
+ skip_policy: no_ui_change
575
596
  depends_on:
576
597
  - wireframe-validate
577
598
  inputs:
@@ -593,6 +614,7 @@ steps:
593
614
  label: Wireframe review
594
615
  skill: wireframe-review
595
616
  engine_evidence_gate: enforced
617
+ skip_policy: no_ui_change
596
618
  depends_on:
597
619
  - wireframe-render
598
620
  inputs:
@@ -106,7 +106,7 @@ When triage found partial overlap or prior rejection, preserve it as source mate
106
106
  Always create or update:
107
107
 
108
108
  - `.devflow/planning/requirements/baseline.md` — unified requirement baseline
109
- - `.devflow/planning/requirements/baseline.meta.json` — structured metadata. Schema defined in `feature-development.yaml` under `steps[requirement-baseline].meta_output.fields`: `review_status`, `fp_count`, `br_count`, `bc_count`, `blocking_pending_count`, `critical_image_missing_count`, `challenge_pending_count`, `code_reality_missing_count`, `current_feature`, `depends_on_count`, plus optional `pending_items`, `risks`, and `key_decisions`.
109
+ - `.devflow/planning/requirements/baseline.meta.json` — structured metadata. Schema defined in `feature-development.yaml` under `steps[requirement-baseline].meta_output.fields`: `review_status`, `fp_count`, `br_count`, `bc_count`, `blocking_pending_count`, `critical_image_missing_count`, `challenge_pending_count`, `code_reality_missing_count`, `current_feature`, `depends_on_count`, plus optional `pending_items`, `risks`, `key_decisions`, and `ui_change_required`.
110
110
  - `.devflow/planning/requirements/{feature}/traceability.json` — copy the orchestrator `templates/traceability.json` schema and create one atomic `REQ-*` record for every confirmed in-scope requirement. Every record must define `input`、`output`、`behavior`、`orchestration`、`failure`、`verification`; use `N/A` only with a reason when a dimension truly does not apply.
111
111
 
112
112
  Before the CP-1 result is reported, run:
@@ -666,6 +666,8 @@ Write `.devflow/planning/requirements/baseline.meta.json` with the fields requir
666
666
  - `challenge_pending_count`: unanswered CR decisions affecting scope, semantics, ownership, compatibility, implementation cost, or downstream contracts
667
667
  - `code_reality_missing_count`: required code reality comparisons that were not completed
668
668
 
669
+ Also write `ui_change_required` as a JSON boolean. Set it to `false` only when the confirmed baseline explicitly states that the feature adds or changes no page, interaction, displayed/input field, or UI permission behavior. Set it to `true` for any UI impact or ambiguity. This field is a candidate fact only: never edit `STATE.md`, skip a step, or bypass a checkpoint. Core evaluates the field after CP-1 is approved and records any controlled skip.
670
+
669
671
  Completion gate: baseline and baseline.meta.json are written and contain no unresolved template placeholders. If updating an existing baseline, ALL prior feature points are still present.
670
672
 
671
673
  ## Phase 7 — Final Review Gate (MANDATORY — do not skip)
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.101";
2
+ export const PRODUCT_VERSION = "0.1.102";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5