@guanlin4924/devflow-pi 0.1.38 → 0.1.39
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/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +54 -6
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +94 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +30 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +64 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +40 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +88 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +73 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_coverage.rb +173 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +14 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +55 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +56 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +24 -0
- package/src/constants.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { appendFile, mkdir, rename, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
@@ -864,6 +864,27 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
864
864
|
let controlState: SessionControlState = initialControlState();
|
|
865
865
|
let activeContract: ActiveExecutionContract | undefined;
|
|
866
866
|
let contractAgentCalls = 0;
|
|
867
|
+
let projectRoot: string | undefined;
|
|
868
|
+
|
|
869
|
+
async function appendEngineEvidence(cwd: string | undefined, event: string, extra: Record<string, unknown> = {}) {
|
|
870
|
+
if (!cwd || !activeContract) return;
|
|
871
|
+
const feature = activeContract.feature;
|
|
872
|
+
const step = activeContract.step;
|
|
873
|
+
if (!/^[\p{L}\p{N}._-]{1,160}$/u.test(feature) || !/^[\p{L}\p{N}._-]{1,160}$/u.test(step)) return;
|
|
874
|
+
const directory = path.join(cwd, ".planning", "evidence", feature);
|
|
875
|
+
const destination = path.join(directory, `${step}.engine-events.jsonl`);
|
|
876
|
+
await mkdir(directory, { recursive: true });
|
|
877
|
+
await appendFile(destination, `${JSON.stringify({
|
|
878
|
+
schema_version: 1,
|
|
879
|
+
timestamp: new Date().toISOString(),
|
|
880
|
+
session_id: sessionId,
|
|
881
|
+
feature,
|
|
882
|
+
step,
|
|
883
|
+
event,
|
|
884
|
+
collector: "devflow-runtime/harness",
|
|
885
|
+
...extra,
|
|
886
|
+
})}\n`, "utf8");
|
|
887
|
+
}
|
|
867
888
|
|
|
868
889
|
function controlGuide(state: SessionControlState): string[] {
|
|
869
890
|
const title = "Devflow 操作指引";
|
|
@@ -1338,6 +1359,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1338
1359
|
pi.setLabel("Devflow Project Runtime");
|
|
1339
1360
|
|
|
1340
1361
|
pi.on("session_start", async (_event, ctx) => {
|
|
1362
|
+
projectRoot = ctx.cwd;
|
|
1341
1363
|
sessionId = randomUUID();
|
|
1342
1364
|
const result = await runHook(ctx.cwd, "start.py", {
|
|
1343
1365
|
session_id: sessionId,
|
|
@@ -1452,9 +1474,13 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1452
1474
|
await triggerWorkflow(next.snapshot, "approval");
|
|
1453
1475
|
});
|
|
1454
1476
|
|
|
1455
|
-
pi.on("before_agent_start", async event =>
|
|
1456
|
-
|
|
1457
|
-
|
|
1477
|
+
pi.on("before_agent_start", async event => {
|
|
1478
|
+
if (activeWorkflowTurn && activeContract) {
|
|
1479
|
+
await appendEngineEvidence(projectRoot, "ai_runtime_initialized", { source: "runtime_before_agent_start" });
|
|
1480
|
+
await appendEngineEvidence(projectRoot, "ai_invocation", { source: "runtime_before_agent_start" });
|
|
1481
|
+
}
|
|
1482
|
+
return { systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY] };
|
|
1483
|
+
});
|
|
1458
1484
|
|
|
1459
1485
|
pi.on("agent_end", async (event, ctx) => {
|
|
1460
1486
|
if (!activeWorkflowTurn || event.willContinue) return;
|
|
@@ -1543,7 +1569,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1543
1569
|
"--json",
|
|
1544
1570
|
"--expected-revision",
|
|
1545
1571
|
String(revision),
|
|
1546
|
-
...(checkpoint ? ["--confirm-checkpoint"] : ["--complete-step", stepId!]),
|
|
1572
|
+
...(checkpoint ? ["--confirm-checkpoint", "--runtime-confirmed"] : ["--complete-step", stepId!]),
|
|
1547
1573
|
]);
|
|
1548
1574
|
if (!result.ok) {
|
|
1549
1575
|
setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "推进校验失败" });
|
|
@@ -1691,6 +1717,18 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1691
1717
|
"Devflow 状态迁移只能由运行时在用户明确批准已展示的步骤或 checkpoint 后执行;LLM 不得自行推进。",
|
|
1692
1718
|
};
|
|
1693
1719
|
}
|
|
1720
|
+
if (isUserConfirmedStepTransition && activeContract) {
|
|
1721
|
+
const evidence = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/machine_evidence.rb", [
|
|
1722
|
+
"--feature", activeContract.feature, "--step", activeContract.step, "--write", "--json",
|
|
1723
|
+
]);
|
|
1724
|
+
const evidencePayload = parseJsonResult(evidence);
|
|
1725
|
+
if (!evidence.ok || !evidencePayload || evidencePayload.complete !== true) {
|
|
1726
|
+
return {
|
|
1727
|
+
block: true,
|
|
1728
|
+
reason: `Devflow machine evidence is incomplete; collect independent runtime evidence before completing ${activeContract.step}.`,
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1694
1732
|
if (
|
|
1695
1733
|
!toolName.startsWith("devflow_") &&
|
|
1696
1734
|
["write", "edit", "apply_patch", "notebook_edit", "bash", "shell", "exec_command"].includes(toolName) &&
|
|
@@ -1767,6 +1805,12 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1767
1805
|
|
|
1768
1806
|
pi.on("tool_result", async (event, ctx) => {
|
|
1769
1807
|
if (event.isError) return;
|
|
1808
|
+
if (activeWorkflowTurn && activeContract) {
|
|
1809
|
+
await appendEngineEvidence(ctx.cwd, "engine_dispatched", {
|
|
1810
|
+
engine: event.toolName,
|
|
1811
|
+
ai_capable: event.toolName === "task" || event.toolName.toLowerCase().includes("ai") || event.toolName.toLowerCase().includes("midscene"),
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1770
1814
|
await runHook(ctx.cwd, "post_tool.py", {
|
|
1771
1815
|
session_id: sessionId,
|
|
1772
1816
|
cwd: ctx.cwd,
|
|
@@ -1867,7 +1911,11 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1867
1911
|
stderr: "TC candidates are accepted only during the active acceptance-test-design contract.",
|
|
1868
1912
|
});
|
|
1869
1913
|
}
|
|
1870
|
-
args.push("--
|
|
1914
|
+
args.push("--expected-revision", String(params.expectedRevision), "--delta-json", JSON.stringify(params.delta));
|
|
1915
|
+
// `write` is an explicit dry-run switch. Omit it only when the caller
|
|
1916
|
+
// explicitly asks for a preview; the normal approved proposal remains
|
|
1917
|
+
// a Core-owned write.
|
|
1918
|
+
if (params.write !== false) args.push("--write");
|
|
1871
1919
|
if (activeContract?.allowedGraphDelta?.length) args.push("--allowed-kinds", activeContract.allowedGraphDelta.join(","));
|
|
1872
1920
|
}
|
|
1873
1921
|
if (params.action === "confirm") {
|
|
@@ -12,6 +12,7 @@ require_relative "control_io"
|
|
|
12
12
|
require_relative "validate_state"
|
|
13
13
|
require_relative "phase_ownership"
|
|
14
14
|
require_relative "requirement_graph"
|
|
15
|
+
require_relative "gate_runtime"
|
|
15
16
|
|
|
16
17
|
options = {
|
|
17
18
|
state: nil,
|
|
@@ -24,7 +25,8 @@ options = {
|
|
|
24
25
|
cancel: false,
|
|
25
26
|
cancel_reason: nil,
|
|
26
27
|
expected_revision: nil,
|
|
27
|
-
json: false
|
|
28
|
+
json: false,
|
|
29
|
+
runtime_confirmed: false
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
OptionParser.new do |opts|
|
|
@@ -40,6 +42,7 @@ OptionParser.new do |opts|
|
|
|
40
42
|
opts.on("--cancel-reason REASON", "Record why the active feature was cancelled") { |reason| options[:cancel_reason] = reason }
|
|
41
43
|
opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
|
|
42
44
|
opts.on("--json", "Print JSON output") { options[:json] = true }
|
|
45
|
+
opts.on("--runtime-confirmed", "Internal runtime authorization for checkpoint confirmation") { options[:runtime_confirmed] = true }
|
|
43
46
|
end.parse!
|
|
44
47
|
|
|
45
48
|
root = Dir.pwd
|
|
@@ -64,6 +67,63 @@ def load_workflow(root, state)
|
|
|
64
67
|
[relative, load_yaml_file(path)]
|
|
65
68
|
end
|
|
66
69
|
|
|
70
|
+
def gate_contract_enabled?(workflow)
|
|
71
|
+
workflow.dig("governance", "gate_contract_version").to_i >= 3
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Build the runtime view from the existing workflow declaration. This keeps
|
|
75
|
+
# governance.step_checkers as the single checker namespace and avoids creating
|
|
76
|
+
# a second hand-maintained per-step transition table.
|
|
77
|
+
def transition_gate_contract(workflow, step)
|
|
78
|
+
checkpoints = workflow["checkpoints"] || {}
|
|
79
|
+
checkpoint_id = step["checkpoint"]
|
|
80
|
+
checkpoint = checkpoint_id && checkpoints[checkpoint_id]
|
|
81
|
+
track_names = Array(workflow["parallel_windows"]).flat_map do |window|
|
|
82
|
+
next [] unless window.is_a?(Hash) && window["branches"].is_a?(Hash)
|
|
83
|
+
window["branches"].keys.map { |name| name.to_s.sub(/\Atrack_/, "").upcase }
|
|
84
|
+
end
|
|
85
|
+
allowed_tracks = track_names.empty? ? ["main"] : ["main", *track_names]
|
|
86
|
+
reentries = (workflow["failure_branches"] || {}).each_with_object([]) do |(source_step, branch), entries|
|
|
87
|
+
next unless branch.is_a?(Hash) && branch["resume_after_success"].is_a?(Hash)
|
|
88
|
+
entries << { "source" => { "flow" => branch["workflow"], "step" => source_step }, "target" => branch["resume_after_success"], "requires_checkpoint_reconfirm" => true }
|
|
89
|
+
end
|
|
90
|
+
{
|
|
91
|
+
"gate_id" => "#{step.fetch("id")}-gate-v1",
|
|
92
|
+
"step_id" => step.fetch("id"),
|
|
93
|
+
"topology" => {
|
|
94
|
+
"allowed_tracks" => allowed_tracks,
|
|
95
|
+
"allowed_predecessors" => Array(step["depends_on"]),
|
|
96
|
+
"allowed_reentry" => reentries,
|
|
97
|
+
"checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
|
|
98
|
+
},
|
|
99
|
+
"entry" => { "required_inputs" => Array(step["inputs"]) },
|
|
100
|
+
"outputs" => { "required" => Array(step["outputs"]).select { |output| output["required"] } },
|
|
101
|
+
"quality" => { "checker_ids" => Array(workflow.dig("governance", "step_checkers", step["id"])) },
|
|
102
|
+
"concurrency" => { "project_lock" => "required", "optimistic_revision" => "required", "registry_view" => "candidate_overlay" },
|
|
103
|
+
"recovery" => { "allowed" => ["sync_artifacts", "repair_outputs"], "max_attempts" => 2 }
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def transition_gate_preflight(workflow, state, step, current_revision, checkpoint_authorized = false, transition_kind = nil)
|
|
108
|
+
return nil unless gate_contract_enabled?(workflow)
|
|
109
|
+
|
|
110
|
+
contract = transition_gate_contract(workflow, step)
|
|
111
|
+
track = state["active_track"].to_s
|
|
112
|
+
track = "main" unless %w[A B].include?(track)
|
|
113
|
+
GateRuntime.evaluate(
|
|
114
|
+
contract: contract,
|
|
115
|
+
workflow_state: state,
|
|
116
|
+
current_revision: current_revision,
|
|
117
|
+
expected_revision: current_revision,
|
|
118
|
+
track: track,
|
|
119
|
+
source: "advance_state",
|
|
120
|
+
root: Dir.pwd,
|
|
121
|
+
check_outputs: step["id"].to_s == state["current_step"].to_s,
|
|
122
|
+
transition_kind: transition_kind,
|
|
123
|
+
checkpoint_authorized: checkpoint_authorized
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
67
127
|
def interpolate_path(path, state)
|
|
68
128
|
path
|
|
69
129
|
.gsub("{feature}", state.fetch("current_feature", "").to_s)
|
|
@@ -516,6 +576,39 @@ result =
|
|
|
516
576
|
)
|
|
517
577
|
end
|
|
518
578
|
|
|
579
|
+
# GateRuntime is a read-only boundary check. It runs while the project
|
|
580
|
+
# lock is held and before any candidate state mutation; the existing
|
|
581
|
+
# validator and advance_state CAS remain the final write guard.
|
|
582
|
+
if result.nil? && gate_contract_enabled?(workflow) &&
|
|
583
|
+
!%w[prepared claimed].include?(state.dig("rollover", "status")) &&
|
|
584
|
+
(options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch])
|
|
585
|
+
target_step_id = if options[:complete_step]
|
|
586
|
+
options[:complete_step]
|
|
587
|
+
elsif options[:confirm_checkpoint]
|
|
588
|
+
state["current_step"]
|
|
589
|
+
else
|
|
590
|
+
state["current_step"]
|
|
591
|
+
end
|
|
592
|
+
target_step = step_by_id[target_step_id]
|
|
593
|
+
if target_step.nil?
|
|
594
|
+
result = fail_result("gate_target_step_unknown", "step" => target_step_id)
|
|
595
|
+
elsif options[:complete_step] && state["current_step"].to_s != target_step_id.to_s
|
|
596
|
+
result = fail_result("gate_current_step_mismatch", "current_step" => state["current_step"], "requested_step" => target_step_id)
|
|
597
|
+
else
|
|
598
|
+
transition_kind = if options[:confirm_checkpoint]
|
|
599
|
+
"confirm_checkpoint"
|
|
600
|
+
elsif options[:enter_failure_branch]
|
|
601
|
+
"enter_failure_branch"
|
|
602
|
+
elsif options[:exit_failure_branch]
|
|
603
|
+
"exit_failure_branch"
|
|
604
|
+
end
|
|
605
|
+
gate_result = transition_gate_preflight(workflow, state, target_step, current_revision, options[:runtime_confirmed], transition_kind)
|
|
606
|
+
if gate_result && gate_result["verdict"] != "allow"
|
|
607
|
+
result = fail_result("gate_boundary_rejected", "gate" => gate_result)
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
519
612
|
if result.nil? && options[:checkpoint_decision]
|
|
520
613
|
state["checkpoint_decision"] = options[:checkpoint_decision]
|
|
521
614
|
changed = true
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module CheckerRegistry
|
|
5
|
+
DECLARED = %w[
|
|
6
|
+
requirement-graph-check slice-independence-check acceptance-coverage-check
|
|
7
|
+
api-test-schema-check contract-coverage-check api-test-matrix-check
|
|
8
|
+
test-coverage-check test-obligation-check acceptance-test-results-check
|
|
9
|
+
change-impact-check tdd-evidence-check test-task-binding-check
|
|
10
|
+
].freeze
|
|
11
|
+
EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def known?(id)
|
|
16
|
+
(DECLARED + EMBEDDED).include?(id.to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def kind(id)
|
|
20
|
+
return "declared" if DECLARED.include?(id.to_s)
|
|
21
|
+
return "embedded" if EMBEDDED.include?(id.to_s)
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate!(ids)
|
|
26
|
+
unknown = Array(ids).map(&:to_s).reject { |id| known?(id) }
|
|
27
|
+
raise ArgumentError, "unknown checker id(s): #{unknown.join(", ")}" unless unknown.empty?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module GateContract
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def load(path)
|
|
10
|
+
value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
11
|
+
validate!(value)
|
|
12
|
+
value
|
|
13
|
+
rescue Psych::Exception => e
|
|
14
|
+
raise ArgumentError, "invalid GateContract YAML: #{e.message}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def validate!(contract)
|
|
18
|
+
raise ArgumentError, "GateContract must be a mapping" unless contract.is_a?(Hash)
|
|
19
|
+
%w[gate_id step_id topology entry outputs quality recovery].each do |field|
|
|
20
|
+
raise ArgumentError, "GateContract missing #{field}" unless contract.key?(field)
|
|
21
|
+
end
|
|
22
|
+
topology = contract["topology"]
|
|
23
|
+
raise ArgumentError, "topology must be a mapping" unless topology.is_a?(Hash)
|
|
24
|
+
%w[allowed_tracks allowed_predecessors].each do |field|
|
|
25
|
+
raise ArgumentError, "#{field} must be an array" unless topology[field].is_a?(Array)
|
|
26
|
+
end
|
|
27
|
+
raise ArgumentError, "allowed_reentry must be an array" unless topology["allowed_reentry"].is_a?(Array)
|
|
28
|
+
validate_reentries!(topology["allowed_reentry"])
|
|
29
|
+
checkpoint = topology["checkpoint"]
|
|
30
|
+
validate_checkpoint!(checkpoint) unless checkpoint.nil?
|
|
31
|
+
concurrency = contract["concurrency"]
|
|
32
|
+
raise ArgumentError, "concurrency must be a mapping" unless concurrency.is_a?(Hash)
|
|
33
|
+
%w[project_lock optimistic_revision registry_view].each do |field|
|
|
34
|
+
raise ArgumentError, "concurrency missing #{field}" unless concurrency.key?(field)
|
|
35
|
+
end
|
|
36
|
+
raise ArgumentError, "project_lock must be required" unless concurrency["project_lock"].to_s == "required"
|
|
37
|
+
raise ArgumentError, "optimistic_revision must be required" unless concurrency["optimistic_revision"].to_s == "required"
|
|
38
|
+
raise ArgumentError, "registry_view must be candidate_overlay" unless concurrency["registry_view"].to_s == "candidate_overlay"
|
|
39
|
+
recovery = contract["recovery"]
|
|
40
|
+
raise ArgumentError, "recovery must be a mapping" unless recovery.is_a?(Hash)
|
|
41
|
+
forbidden = Array(recovery["allowed"]).select { |action| action.to_s.start_with?("reenter_") || %w[confirm_checkpoint transition].include?(action.to_s) }
|
|
42
|
+
raise ArgumentError, "state transition cannot be an automatic recovery action: #{forbidden.join(", ")}" unless forbidden.empty?
|
|
43
|
+
contract
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_reentries!(entries)
|
|
47
|
+
entries.each do |entry|
|
|
48
|
+
raise ArgumentError, "reentry must be a mapping" unless entry.is_a?(Hash)
|
|
49
|
+
source = entry["source"]
|
|
50
|
+
target = entry["target"]
|
|
51
|
+
raise ArgumentError, "reentry source/target are required" unless source.is_a?(Hash) && target.is_a?(Hash)
|
|
52
|
+
%w[flow step].each { |field| raise ArgumentError, "reentry target missing #{field}" if target[field].to_s.empty? }
|
|
53
|
+
raise ArgumentError, "reentry must declare checkpoint reconfirmation" unless entry.key?("requires_checkpoint_reconfirm")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_checkpoint!(checkpoint)
|
|
58
|
+
raise ArgumentError, "checkpoint must be a mapping" unless checkpoint.is_a?(Hash)
|
|
59
|
+
%w[id producing_step required_outputs required_meta_output prompt confirm_options skip_policy resume_behavior].each do |field|
|
|
60
|
+
raise ArgumentError, "checkpoint missing #{field}" unless checkpoint.key?(field)
|
|
61
|
+
end
|
|
62
|
+
raise ArgumentError, "checkpoint confirm_options must be an array" unless checkpoint["confirm_options"].is_a?(Array)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GateResult
|
|
5
|
+
VERDICTS = %w[allow allow_with_warning await_user waiting_external_input block_unsafe].freeze
|
|
6
|
+
CONTROL_PLANE_ERROR = "control_plane_error"
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def build(verdict:, gate_id:, step_id:, errors: [], warnings: [], recovery: nil, evidence: {}, revisions: {})
|
|
11
|
+
valid = VERDICTS.include?(verdict.to_s) || verdict.to_s == CONTROL_PLANE_ERROR
|
|
12
|
+
raise ArgumentError, "invalid gate verdict: #{verdict}" unless valid
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"ok" => %w[allow allow_with_warning].include?(verdict.to_s),
|
|
16
|
+
"verdict" => verdict.to_s,
|
|
17
|
+
"gate_id" => gate_id.to_s,
|
|
18
|
+
"step_id" => step_id.to_s,
|
|
19
|
+
"errors" => Array(errors),
|
|
20
|
+
"warnings" => Array(warnings),
|
|
21
|
+
"recovery" => recovery,
|
|
22
|
+
"evidence" => evidence.is_a?(Hash) ? evidence : {},
|
|
23
|
+
"revisions" => revisions.is_a?(Hash) ? revisions : {}
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def allow(gate_id:, step_id:, **kwargs)
|
|
28
|
+
build(verdict: "allow", gate_id: gate_id, step_id: step_id, **kwargs)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def await_user(gate_id:, step_id:, recovery: nil, **kwargs)
|
|
32
|
+
build(verdict: "await_user", gate_id: gate_id, step_id: step_id, recovery: recovery, **kwargs)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def block(gate_id:, step_id:, code:, message:, recovery: nil, details: {}, **kwargs)
|
|
36
|
+
error = { "code" => code.to_s, "message" => message.to_s }
|
|
37
|
+
error.merge!(details) if details.is_a?(Hash)
|
|
38
|
+
build(verdict: "block_unsafe", gate_id: gate_id, step_id: step_id, errors: [error], recovery: recovery, **kwargs)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "gate_result"
|
|
5
|
+
require_relative "gate_contract"
|
|
6
|
+
require_relative "checker_registry"
|
|
7
|
+
|
|
8
|
+
module GateRuntime
|
|
9
|
+
WORKFLOW_STATUSES = %w[in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def validate_contract!(contract)
|
|
14
|
+
GateContract.validate!(contract)
|
|
15
|
+
CheckerRegistry.validate!(Array(contract.dig("quality", "checker_ids")))
|
|
16
|
+
contract
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def evaluate(contract:, workflow_state:, current_revision:, expected_revision:, track: nil, source: "harness", evidence: {}, root: nil, check_outputs: false, transition_kind: nil, checkpoint_authorized: false)
|
|
20
|
+
validate_contract!(contract)
|
|
21
|
+
gate_id = contract.fetch("gate_id")
|
|
22
|
+
step_id = contract.fetch("step_id")
|
|
23
|
+
base = { gate_id: gate_id, step_id: step_id, evidence: evidence.merge("collector" => source), revisions: { "state" => current_revision } }
|
|
24
|
+
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
|
|
25
|
+
unless WORKFLOW_STATUSES.include?(workflow_state["status"].to_s)
|
|
26
|
+
return GateResult.block(**base, code: "WORKFLOW_STATUS_INVALID", message: "workflow status is not recognized")
|
|
27
|
+
end
|
|
28
|
+
unless %w[confirm_checkpoint enter_failure_branch exit_failure_branch].include?(transition_kind)
|
|
29
|
+
allowed_tracks = Array(contract.dig("topology", "allowed_tracks"))
|
|
30
|
+
if track && !allowed_tracks.empty? && !allowed_tracks.include?(track)
|
|
31
|
+
return GateResult.block(**base, code: "TRACK_SCOPE_VIOLATION", message: "current track is not allowed by GateContract", recovery: { "kind" => "registered_reentry" })
|
|
32
|
+
end
|
|
33
|
+
allowed_predecessors = Array(contract.dig("topology", "allowed_predecessors"))
|
|
34
|
+
previous = workflow_state["last_completed_step"] || workflow_state["previous_step"]
|
|
35
|
+
if previous && !allowed_predecessors.empty? && !allowed_predecessors.include?(previous)
|
|
36
|
+
return GateResult.block(**base, code: "STEP_PREDECESSOR_INVALID", message: "previous step is not allowed by GateContract")
|
|
37
|
+
end
|
|
38
|
+
if root
|
|
39
|
+
missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state)
|
|
40
|
+
return GateResult.block(**base, code: "REQUIRED_INPUT_MISSING", message: "required gate input is missing", details: { "missing" => missing }) unless missing.empty?
|
|
41
|
+
if check_outputs
|
|
42
|
+
missing_outputs = required_paths(contract.dig("outputs", "required"), root, workflow_state)
|
|
43
|
+
return GateResult.block(**base, code: "REQUIRED_OUTPUT_MISSING", message: "required step output is missing", details: { "missing" => missing_outputs }) unless missing_outputs.empty?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
if transition_kind == "confirm_checkpoint"
|
|
48
|
+
checkpoint_id = workflow_state["checkpoint"].to_s
|
|
49
|
+
checkpoint = contract.dig("topology", "checkpoint")
|
|
50
|
+
checkpoint_matches = checkpoint.is_a?(Hash) && checkpoint["id"].to_s == checkpoint_id
|
|
51
|
+
unless workflow_state["status"].to_s == "waiting_checkpoint" && checkpoint_matches && checkpoint_authorized
|
|
52
|
+
return GateResult.block(**base, code: "CHECKPOINT_NOT_AUTHORIZED", message: "checkpoint confirmation is not authorized by the runtime", details: { "checkpoint" => checkpoint_id })
|
|
53
|
+
end
|
|
54
|
+
unless workflow_state["completed_steps"].to_a.include?(checkpoint["producing_step"].to_s)
|
|
55
|
+
return GateResult.block(**base, code: "CHECKPOINT_PRODUCER_INCOMPLETE", message: "checkpoint producing step is not completed", details: { "checkpoint" => checkpoint_id })
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
GateResult.allow(**base)
|
|
59
|
+
rescue KeyError, ArgumentError => e
|
|
60
|
+
GateResult.block(gate_id: contract.is_a?(Hash) ? contract["gate_id"] : "unknown", step_id: contract.is_a?(Hash) ? contract["step_id"] : "unknown", code: "GATE_CONTRACT_INVALID", message: e.message)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def run_checker(contract:, workflow_state:, current_revision:, expected_revision:, checker:, **kwargs)
|
|
64
|
+
boundary = evaluate(contract: contract, workflow_state: workflow_state, current_revision: current_revision, expected_revision: expected_revision, **kwargs)
|
|
65
|
+
return boundary unless boundary["verdict"] == "allow"
|
|
66
|
+
result = checker.call
|
|
67
|
+
raise ArgumentError, "checker must return a GateResult hash" unless result.is_a?(Hash) && result["verdict"]
|
|
68
|
+
result.merge(
|
|
69
|
+
"gate_id" => result["gate_id"] || boundary["gate_id"],
|
|
70
|
+
"step_id" => result["step_id"] || boundary["step_id"],
|
|
71
|
+
"evidence" => boundary.fetch("evidence", {}).merge(result.fetch("evidence", {})),
|
|
72
|
+
"revisions" => boundary.fetch("revisions", {}).merge(result.fetch("revisions", {}))
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def required_paths(entries, root, state)
|
|
77
|
+
Array(entries).each_with_object([]) do |entry, missing|
|
|
78
|
+
raw = entry.is_a?(Hash) ? entry["path"] : entry
|
|
79
|
+
next if raw.to_s.empty?
|
|
80
|
+
relative = raw.to_s.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
81
|
+
absolute = File.expand_path(relative, root)
|
|
82
|
+
root_real = File.realpath(root)
|
|
83
|
+
actual = File.exist?(absolute) ? File.realpath(absolute) : absolute
|
|
84
|
+
next if actual.start_with?("#{root_real}#{File::SEPARATOR}") && File.exist?(actual)
|
|
85
|
+
missing << relative
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -10,6 +10,7 @@ require "optparse"
|
|
|
10
10
|
require "yaml"
|
|
11
11
|
require_relative "control_io"
|
|
12
12
|
require_relative "requirement_graph"
|
|
13
|
+
require_relative "checker_registry"
|
|
13
14
|
|
|
14
15
|
options = { feature: nil, step: nil, contract: nil, write: false, json: false }
|
|
15
16
|
OptionParser.new do |o|
|
|
@@ -33,6 +34,69 @@ def interpolate(path, state)
|
|
|
33
34
|
path.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
def file_evidence(root, relative)
|
|
38
|
+
absolute, = DevflowControlIO.resolve_control_path(root, relative, :artifact)
|
|
39
|
+
return { "path" => relative, "exists" => false } unless File.file?(absolute)
|
|
40
|
+
|
|
41
|
+
{ "path" => relative, "exists" => true, "sha256" => Digest::SHA256.file(absolute).hexdigest, "bytes" => File.size(absolute) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def independent_evidence(root, feature, step, state, workflow, graph, files, checker_results)
|
|
45
|
+
source_nodes = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "source" }
|
|
46
|
+
authority = source_nodes.map do |node|
|
|
47
|
+
evidence = node["evidence"].is_a?(Hash) ? node["evidence"] : {}
|
|
48
|
+
{
|
|
49
|
+
"source_id" => node["id"], "authority" => evidence["authority"], "path" => evidence["path"] || evidence["ref"],
|
|
50
|
+
"fingerprint" => evidence["sha256"] || evidence["source_document_fingerprint"]
|
|
51
|
+
}.compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The harness may publish an append-only event log beside the machine pack.
|
|
55
|
+
# Absence is represented as unavailable, never as false/zero, so the pack
|
|
56
|
+
# cannot manufacture proof that an engine did not invoke AI.
|
|
57
|
+
event_path = File.join(".planning", "evidence", feature, "#{step}.engine-events.jsonl")
|
|
58
|
+
event_absolute, = DevflowControlIO.resolve_control_path(root, event_path, :artifact)
|
|
59
|
+
events = if File.file?(event_absolute)
|
|
60
|
+
File.foreach(event_absolute).first(10_000).each_with_object([]) do |line, parsed|
|
|
61
|
+
next if line.strip.empty?
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
parsed << JSON.parse(line)
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
# A malformed event is not evidence of a clean execution;
|
|
67
|
+
# retain the valid events and let the collector expose the
|
|
68
|
+
# observed subset without inventing a zero count.
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
else
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
ai_events = events ? events.select { |event| %w[ai_runtime_initialized ai_invocation].include?(event["event"]) } : []
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
"collector" => "core.machine_evidence/v1",
|
|
78
|
+
"state" => { "revision" => state["revision"], "step" => step },
|
|
79
|
+
"source_authority" => { "status" => authority.empty? ? "unavailable" : "observed", "sources" => authority },
|
|
80
|
+
"candidate_binding" => {
|
|
81
|
+
"status" => "observed",
|
|
82
|
+
"graph_revision" => graph["revision"],
|
|
83
|
+
"test_case_ids" => Array(graph["nodes"]).each_with_object([]) { |node, ids| ids << node["id"] if node.is_a?(Hash) && node["kind"] == "test_case" }
|
|
84
|
+
},
|
|
85
|
+
"structured_execution" => {
|
|
86
|
+
"status" => "observed",
|
|
87
|
+
"required_files" => files,
|
|
88
|
+
"checker_ids" => checker_results.map { |item| item["id"] }
|
|
89
|
+
},
|
|
90
|
+
"engine_evidence" => {
|
|
91
|
+
"collector" => events ? "harness.engine-events/v1" : "unavailable",
|
|
92
|
+
"event_log" => event_path,
|
|
93
|
+
"ai_runtime_initialized" => events ? ai_events.any? { |event| event["event"] == "ai_runtime_initialized" } : nil,
|
|
94
|
+
"ai_invocation_count" => events ? ai_events.count { |event| event["event"] == "ai_invocation" } : nil,
|
|
95
|
+
"evidence_status" => events ? "observed" : "unavailable"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
36
100
|
def configured_checkers(workflow, step_id)
|
|
37
101
|
explicit = workflow.dig("governance", "step_checkers", step_id)
|
|
38
102
|
checkers = if explicit
|
|
@@ -98,7 +162,7 @@ begin
|
|
|
98
162
|
unless state["current_feature"] == options[:feature] && state["current_step"] == options[:step]
|
|
99
163
|
result = { "ok" => false, "reason" => "state_step_mismatch", "state_feature" => state["current_feature"], "state_step" => state["current_step"] }
|
|
100
164
|
puts JSON.pretty_generate(result)
|
|
101
|
-
exit
|
|
165
|
+
exit 1
|
|
102
166
|
end
|
|
103
167
|
workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
|
|
104
168
|
workflow_raw = File.read(workflow_path)
|
|
@@ -121,6 +185,7 @@ begin
|
|
|
121
185
|
graph, = RequirementGraph.load_graph(root, options[:feature])
|
|
122
186
|
checkers = RequirementGraph.checker_results(graph)
|
|
123
187
|
selected = configured_checkers(workflow, options[:step])
|
|
188
|
+
CheckerRegistry.validate!(selected)
|
|
124
189
|
gate = workflow.dig("governance", "requirement_graph_gate").to_s
|
|
125
190
|
enforced = gate == "enforced"
|
|
126
191
|
checker_results = selected.map do |id|
|
|
@@ -133,6 +198,7 @@ begin
|
|
|
133
198
|
end
|
|
134
199
|
graph_missing = graph["revision"].zero? && !File.exist?(RequirementGraph.graph_path(root, options[:feature]).first)
|
|
135
200
|
checker_passed = !enforced || (!graph_missing && checker_results.all? { |item| item["passed"] })
|
|
201
|
+
engine_evidence_gate = (step["engine_evidence_gate"] || workflow.dig("governance", "engine_evidence_gate")).to_s
|
|
136
202
|
contract_hash = nil
|
|
137
203
|
if options[:contract]
|
|
138
204
|
contract_path = File.expand_path(options[:contract], root)
|
|
@@ -140,14 +206,18 @@ begin
|
|
|
140
206
|
contract_hash = Digest::SHA256.file(contract_path).hexdigest
|
|
141
207
|
end
|
|
142
208
|
node_counts = Array(graph["nodes"]).group_by { |node| node["kind"] }.transform_values(&:length)
|
|
209
|
+
independent = independent_evidence(root, options[:feature], options[:step], state, workflow, graph, files, checker_results)
|
|
210
|
+
engine_evidence_complete = independent.dig("engine_evidence", "evidence_status") == "observed"
|
|
211
|
+
evidence_complete = engine_evidence_gate != "enforced" || engine_evidence_complete
|
|
143
212
|
pack = {
|
|
144
213
|
"schema_version" => 2, "origin" => "core_machine", "feature" => options[:feature], "step" => options[:step],
|
|
145
214
|
"state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw),
|
|
146
|
-
"contract_hash" => contract_hash, "files" => files, "checkers" => checker_results,
|
|
215
|
+
"contract_hash" => contract_hash, "files" => files, "checkers" => checker_results, "evidence" => independent,
|
|
147
216
|
"counters" => { "required_file_count" => files.length, "existing_file_count" => files.count { |item| item["exists"] }, "nodes_by_kind" => node_counts,
|
|
148
217
|
"stale_node_count" => Array(graph["stale"]).length, "stale_view_count" => Hash(graph["views"]).count { |_name, view| view["needs_recheck"] } },
|
|
149
218
|
"view_fingerprints" => Hash(graph["views"]).transform_values { |view| view.select { |key, _value| %w[fingerprint graph_revision state needs_recheck stale_fingerprint].include?(key) } },
|
|
150
|
-
"complete" => files.all? { |item| item["exists"] } && checker_passed,
|
|
219
|
+
"complete" => files.all? { |item| item["exists"] } && checker_passed && evidence_complete,
|
|
220
|
+
"evidence_complete" => evidence_complete,
|
|
151
221
|
"gate_mode" => gate.empty? ? "observe" : gate
|
|
152
222
|
}.compact
|
|
153
223
|
destination = File.join(root, ".planning/evidence", options[:feature], "#{options[:step]}.machine.json")
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Canonical wayfinder quality checker. It validates references and structured
|
|
5
|
+
# coverage only; AI-provided equivalence labels are never trusted.
|
|
6
|
+
require "digest"
|
|
7
|
+
require "json"
|
|
8
|
+
require "optparse"
|
|
9
|
+
require "yaml"
|
|
10
|
+
require_relative "requirement_graph"
|
|
11
|
+
|
|
12
|
+
module RequirementCoverage
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def validate(root:, feature:)
|
|
16
|
+
directory = RequirementGraph.feature_dir(root, feature)
|
|
17
|
+
errors = []
|
|
18
|
+
source = load(File.join(directory, "source_units.yaml"), errors, "SOURCE_UNITS_MISSING")
|
|
19
|
+
coverage = load(File.join(directory, "coverage.yaml"), errors, "COVERAGE_ARTIFACT_MISSING")
|
|
20
|
+
return result(errors, {}, {}) unless source && coverage
|
|
21
|
+
validate_source(source, errors)
|
|
22
|
+
validate_coverage(coverage, errors)
|
|
23
|
+
return result(errors, {}, {}) unless errors.empty?
|
|
24
|
+
|
|
25
|
+
units = Array(source["units"])
|
|
26
|
+
unit_by_id = units.to_h { |unit| [unit["stable_unit_id"].to_s, unit] }
|
|
27
|
+
assertions = units.flat_map { |unit| Array(unit["required_assertions"]) }
|
|
28
|
+
assertion_by_id = assertions.to_h { |assertion| [assertion["assertion_id"].to_s, assertion] }
|
|
29
|
+
claims = Array(coverage["claims"])
|
|
30
|
+
claim_ids = {}
|
|
31
|
+
covered = {}
|
|
32
|
+
uncertain = {}
|
|
33
|
+
approval_required = {}
|
|
34
|
+
|
|
35
|
+
claims.each do |claim|
|
|
36
|
+
id = claim["claim_id"].to_s
|
|
37
|
+
duplicate(errors, claim_ids, id, "DUPLICATE_CLAIM_ID")
|
|
38
|
+
unit_id = claim["source_unit_id"].to_s
|
|
39
|
+
unless unit_by_id.key?(unit_id)
|
|
40
|
+
add(errors, "CLAIM_WITHOUT_UNIT", "claim references an unknown source unit", claim_id: id)
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
Array(claim["assertion_ids"]).each do |assertion_id|
|
|
44
|
+
assertion = assertion_by_id[assertion_id.to_s]
|
|
45
|
+
unless assertion && assertion["source_unit_id"].to_s == unit_id
|
|
46
|
+
add(errors, "ASSERTION_REF_NOT_FOUND", "claim references an unknown assertion", claim_id: id, assertion_id: assertion_id)
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
relation = compare(assertion, claim["inherited_constraints"])
|
|
50
|
+
case relation
|
|
51
|
+
when "equivalent" then covered[assertion_id.to_s] = true
|
|
52
|
+
when "uncertain" then uncertain[assertion_id.to_s] = true
|
|
53
|
+
when "scope_widened", "constraint_tightened"
|
|
54
|
+
approval = claim["approval"]
|
|
55
|
+
expected_content_fingerprint = sha(canonical(claim["inherited_constraints"]))
|
|
56
|
+
if approval.is_a?(Hash) && approval["approval_id"].to_s != "" && approval["claim_id"].to_s == id && approval["assertion_id"].to_s == assertion_id.to_s && approval["relation"].to_s == relation && approval["content_fingerprint"].to_s == expected_content_fingerprint && approval["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
|
|
57
|
+
covered[assertion_id.to_s] = true
|
|
58
|
+
else
|
|
59
|
+
approval_required[assertion_id.to_s] = true
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
add(errors, relation == "under_coverage" ? "ASSERTION_UNCOVERED" : "CONTRADICTORY_CLAIM", "claim is not an equivalent coverage", claim_id: id, assertion_id: assertion_id, relation: relation)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
disposition_by_id = {}
|
|
68
|
+
Array(coverage["units"]).each do |entry|
|
|
69
|
+
unit_id = entry["unit_id"].to_s
|
|
70
|
+
duplicate(errors, disposition_by_id, unit_id, "DUPLICATE_COVERAGE")
|
|
71
|
+
add(errors, "UNIT_REF_NOT_FOUND", "coverage references an unknown unit", unit_id: unit_id) unless unit_by_id.key?(unit_id)
|
|
72
|
+
disposition = entry["disposition"]
|
|
73
|
+
unless disposition.is_a?(Hash) && %w[active deferred out_of_scope merged].include?(disposition["kind"].to_s)
|
|
74
|
+
add(errors, "DISPOSITION_SCHEMA_INVALID", "invalid unit disposition", unit_id: unit_id)
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
if %w[deferred out_of_scope].include?(disposition["kind"].to_s)
|
|
78
|
+
approval = disposition["approval"]
|
|
79
|
+
unless approval.is_a?(Hash) && approval["approval_id"].to_s != "" && approval["unit_id"].to_s == unit_id && approval["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
|
|
80
|
+
add(errors, "DISPOSITION_APPROVAL_MISSING", "deferred/out_of_scope requires a current unit approval", unit_id: unit_id)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
unit_by_id.each_key { |id| add(errors, "UNIT_DISPOSITION_MISSING", "source unit has no disposition", unit_id: id) unless disposition_by_id.key?(id) }
|
|
85
|
+
active = disposition_by_id.select { |_id, entry| entry.dig("disposition", "kind").to_s == "active" }.keys
|
|
86
|
+
active.each do |unit_id|
|
|
87
|
+
Array(unit_by_id[unit_id]["required_assertions"]).each do |assertion|
|
|
88
|
+
id = assertion["assertion_id"].to_s
|
|
89
|
+
next if covered[id] || uncertain[id] || approval_required[id]
|
|
90
|
+
add(errors, "ASSERTION_UNCOVERED", "active assertion has no valid claim", unit_id: unit_id, assertion_id: id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
counts = { "unit_count" => units.length, "assertion_count" => assertions.length, "claim_count" => claims.length, "covered_assertion_count" => covered.length, "uncertain_assertion_count" => uncertain.length, "approval_required_assertion_count" => approval_required.length }
|
|
94
|
+
result(errors, counts, (uncertain.any? || approval_required.any?) ? "await_user" : nil)
|
|
95
|
+
rescue JSON::ParserError, Psych::Exception, ArgumentError => e
|
|
96
|
+
result([{ "code" => "COVERAGE_SCHEMA_INVALID", "message" => e.message }], {}, {})
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def load(path, errors, code)
|
|
100
|
+
unless File.file?(path)
|
|
101
|
+
add(errors, code, "required coverage artifact is missing", path: path)
|
|
102
|
+
return nil
|
|
103
|
+
end
|
|
104
|
+
value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
105
|
+
raise ArgumentError, "coverage artifact must be a mapping" unless value.is_a?(Hash)
|
|
106
|
+
value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_source(source, errors)
|
|
110
|
+
add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "source_units schema_version must be 1") unless source["schema_version"].to_i == 1
|
|
111
|
+
add(errors, "SOURCE_UNITS_NOT_CORE_GENERATED", "source_units must be produced by Core") unless source["produced_by"].to_s == "core.source_unitizer/v1"
|
|
112
|
+
add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "source_document_fingerprint is required") if source["source_document_fingerprint"].to_s.empty?
|
|
113
|
+
add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "units must be an array") unless source["units"].is_a?(Array)
|
|
114
|
+
Array(source["units"]).each do |unit|
|
|
115
|
+
add(errors, "UNIT_SCHEMA_INVALID", "stable_unit_id is required") if unit["stable_unit_id"].to_s.empty?
|
|
116
|
+
add(errors, "SOURCE_UNIT_REUSED_STALE", "unit source fingerprint mismatch", unit_id: unit["stable_unit_id"]) unless unit["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
|
|
117
|
+
add(errors, "EXCERPT_MODIFIED", "excerpt fingerprint mismatch", unit_id: unit["stable_unit_id"]) if unit["excerpt_fingerprint"] && sha(unit["excerpt"]) != unit["excerpt_fingerprint"].to_s
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_coverage(coverage, errors)
|
|
122
|
+
add(errors, "COVERAGE_SCHEMA_INVALID", "coverage schema_version must be 1") unless coverage["schema_version"].to_i == 1
|
|
123
|
+
%w[claims units].each { |key| add(errors, "COVERAGE_SCHEMA_INVALID", "#{key} must be an array") unless coverage[key].is_a?(Array) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def compare(assertion, inherited)
|
|
127
|
+
return "uncertain" unless inherited.is_a?(Hash)
|
|
128
|
+
expected = assertion.select { |key, _| %w[type polarity modality predicate condition role_scope order_constraint data_constraint exception].include?(key) }
|
|
129
|
+
actual = inherited.select { |key, _| expected.key?(key) }
|
|
130
|
+
return "equivalent" if canonical(expected) == canonical(actual)
|
|
131
|
+
return "under_coverage" if expected["polarity"] && actual["polarity"] && expected["polarity"] != actual["polarity"]
|
|
132
|
+
return "under_coverage" if expected["modality"] == "must" && actual["modality"] == "should"
|
|
133
|
+
if expected["role_scope"].is_a?(Hash) && actual["role_scope"].is_a?(Hash)
|
|
134
|
+
expected_include = Array(expected["role_scope"]["include"]); actual_include = Array(actual["role_scope"]["include"])
|
|
135
|
+
return "scope_widened" if (actual_include - expected_include).any?
|
|
136
|
+
return "under_coverage" if (expected_include - actual_include).any?
|
|
137
|
+
end
|
|
138
|
+
"uncertain"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def canonical(value)
|
|
142
|
+
JSON.generate(value.is_a?(Hash) ? value.keys.sort.to_h { |key| [key, canonical_value(value[key])] } : value)
|
|
143
|
+
end
|
|
144
|
+
def canonical_value(value)
|
|
145
|
+
value.is_a?(Hash) ? value.keys.sort.to_h { |key| [key, canonical_value(value[key])] } : value.is_a?(Array) ? value.map { |item| canonical_value(item) } : value
|
|
146
|
+
end
|
|
147
|
+
def sha(value)
|
|
148
|
+
Digest::SHA256.hexdigest(value.to_s)
|
|
149
|
+
end
|
|
150
|
+
def duplicate(errors, seen, id, code)
|
|
151
|
+
add(errors, code, "duplicate identifier", id: id) if seen.key?(id)
|
|
152
|
+
seen[id] = true
|
|
153
|
+
end
|
|
154
|
+
def add(errors, code, message, **extra)
|
|
155
|
+
errors << { "code" => code, "message" => message }.merge(extra)
|
|
156
|
+
end
|
|
157
|
+
def result(errors, counts, forced_verdict = nil)
|
|
158
|
+
verdict = if errors.any? then "block_unsafe" elsif forced_verdict then forced_verdict else "allow" end
|
|
159
|
+
{ "ok" => verdict == "allow", "verdict" => verdict, "errors" => errors, "warnings" => [], "counts" => counts, "evidence" => { "checker" => "requirement-coverage-check" } }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if $PROGRAM_NAME == __FILE__
|
|
164
|
+
options = {}
|
|
165
|
+
OptionParser.new do |opts|
|
|
166
|
+
opts.on("--root PATH") { |value| options[:root] = value }
|
|
167
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
168
|
+
end.parse!
|
|
169
|
+
abort("--root and --feature are required") unless options[:root] && options[:feature]
|
|
170
|
+
payload = RequirementCoverage.validate(root: options[:root], feature: options[:feature])
|
|
171
|
+
puts JSON.pretty_generate(payload)
|
|
172
|
+
exit(payload["verdict"] == "allow" ? 0 : 1)
|
|
173
|
+
end
|
|
@@ -63,6 +63,10 @@ module RequirementGraph
|
|
|
63
63
|
JSON.generate(value)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
def deep_clone(value)
|
|
67
|
+
JSON.parse(JSON.generate(value))
|
|
68
|
+
end
|
|
69
|
+
|
|
66
70
|
def fingerprint(value)
|
|
67
71
|
Digest::SHA256.hexdigest(canonical_json(value))
|
|
68
72
|
end
|
|
@@ -1000,20 +1004,22 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
1000
1004
|
{ "ok" => true, "path" => relative, "context" => ctx }
|
|
1001
1005
|
end
|
|
1002
1006
|
when "propose"
|
|
1003
|
-
raise ArgumentError, "--write is required" unless options[:write]
|
|
1004
1007
|
raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
|
|
1005
1008
|
if graph["revision"] != options[:expected_revision]
|
|
1006
1009
|
{ "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
|
|
1007
1010
|
else
|
|
1008
1011
|
delta = JSON.parse(options[:delta_json].to_s)
|
|
1009
1012
|
allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1013
|
+
candidate = RequirementGraph.deep_clone(graph)
|
|
1014
|
+
applied = RequirementGraph.proposal(candidate, delta, allowed_kinds)
|
|
1015
|
+
candidate["revision"] += 1
|
|
1016
|
+
stale = Array(candidate["stale"]).any?
|
|
1017
|
+
if options[:write]
|
|
1018
|
+
RequirementGraph.write_views!(root, candidate, stale: stale)
|
|
1019
|
+
RequirementGraph.append_audit!(root, candidate, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
|
|
1020
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(candidate) + "\n")
|
|
1021
|
+
end
|
|
1022
|
+
{ "ok" => true, "path" => relative, "written" => options[:write], "revision" => options[:write] ? candidate["revision"] : graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(candidate), "checkers" => RequirementGraph.checker_results(candidate), "views" => candidate["views"] }
|
|
1017
1023
|
end
|
|
1018
1024
|
when "import-test-cases"
|
|
1019
1025
|
raise ArgumentError, "--write is required" unless options[:write]
|
|
@@ -9,6 +9,9 @@ require_relative "control_io"
|
|
|
9
9
|
require_relative "phase_ownership"
|
|
10
10
|
require_relative "requirement_graph"
|
|
11
11
|
require_relative "architecture_feasibility"
|
|
12
|
+
require_relative "checker_registry"
|
|
13
|
+
require_relative "requirement_coverage"
|
|
14
|
+
require_relative "workflow_gate_contract"
|
|
12
15
|
|
|
13
16
|
module WorkflowStateValidator
|
|
14
17
|
REQUIRED_STATE_FIELDS = %w[
|
|
@@ -68,6 +71,8 @@ module WorkflowStateValidator
|
|
|
68
71
|
)
|
|
69
72
|
|
|
70
73
|
steps = Array(workflow["steps"])
|
|
74
|
+
validate_checker_registry(issues, workflow)
|
|
75
|
+
validate_workflow_gate_contract(issues, workflow)
|
|
71
76
|
step_by_id = steps.to_h { |step| [step.fetch("id"), step] }
|
|
72
77
|
step_ids = steps.map { |step| step.fetch("id") }
|
|
73
78
|
completed_ids = Array(state["completed_steps"])
|
|
@@ -77,7 +82,7 @@ module WorkflowStateValidator
|
|
|
77
82
|
validate_step_tracking(issues, state, step_ids, completed_ids, pending_ids, tracked_ids)
|
|
78
83
|
validate_track_scope(issues, state, completed_ids)
|
|
79
84
|
validate_dependencies(issues, step_by_id, completed_ids)
|
|
80
|
-
validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
|
|
85
|
+
validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
|
|
81
86
|
validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
|
|
82
87
|
validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
|
|
83
88
|
graph_gate = workflow.dig("governance", "requirement_graph_gate").to_s
|
|
@@ -264,13 +269,43 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
264
269
|
end
|
|
265
270
|
end
|
|
266
271
|
|
|
267
|
-
def
|
|
272
|
+
def validate_checker_registry(issues, workflow)
|
|
273
|
+
declared = workflow.dig("governance", "step_checkers")
|
|
274
|
+
return unless declared.is_a?(Hash)
|
|
275
|
+
|
|
276
|
+
declared.each do |step_id, checker_ids|
|
|
277
|
+
Array(checker_ids).each do |checker_id|
|
|
278
|
+
next if CheckerRegistry.known?(checker_id)
|
|
279
|
+
|
|
280
|
+
issues << issue(
|
|
281
|
+
"error",
|
|
282
|
+
"unknown_checker_id",
|
|
283
|
+
"Workflow references a checker that is not in the canonical checker registry",
|
|
284
|
+
step: step_id,
|
|
285
|
+
checker_id: checker_id
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def validate_workflow_gate_contract(issues, workflow)
|
|
292
|
+
# GateContract validation is opt-in. Older/custom workflows intentionally
|
|
293
|
+
# use a smaller checkpoint shape and must keep the legacy control-plane
|
|
294
|
+
# behavior until they declare the contract version.
|
|
295
|
+
return unless workflow.dig("governance", "gate_contract_version").to_i >= 3
|
|
296
|
+
|
|
297
|
+
WorkflowGateContract.validate(workflow).each do |entry|
|
|
298
|
+
issues << issue("error", entry["code"], entry["message"], entry.reject { |key, _| %w[code message].include?(key) })
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
|
|
268
303
|
completed_ids.each do |step_id|
|
|
269
304
|
step = step_by_id[step_id]
|
|
270
305
|
next unless step
|
|
271
306
|
|
|
272
307
|
validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, waiting_checkpoint_step?(state, step_id))
|
|
273
|
-
validate_meta_output(issues, root, state, step)
|
|
308
|
+
validate_meta_output(issues, root, state, step, workflow)
|
|
274
309
|
end
|
|
275
310
|
end
|
|
276
311
|
|
|
@@ -390,7 +425,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
390
425
|
end
|
|
391
426
|
end
|
|
392
427
|
|
|
393
|
-
def validate_meta_output(issues, root, state, step)
|
|
428
|
+
def validate_meta_output(issues, root, state, step, workflow = nil)
|
|
394
429
|
meta = step["meta_output"]
|
|
395
430
|
return unless meta
|
|
396
431
|
|
|
@@ -411,6 +446,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
411
446
|
|
|
412
447
|
validate_requirement_triage_gate(issues, step, data)
|
|
413
448
|
validate_requirement_wayfinder_gate(issues, step, data)
|
|
449
|
+
validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
|
|
414
450
|
validate_domain_modeling_gate(issues, step, data)
|
|
415
451
|
validate_requirement_feasibility_review_gate(issues, step, data)
|
|
416
452
|
validate_requirement_baseline_gate(issues, step, data)
|
|
@@ -427,6 +463,21 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
427
463
|
issues << issue("error", "invalid_meta_json", e.message, step: step["id"], path: path)
|
|
428
464
|
end
|
|
429
465
|
|
|
466
|
+
def validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
|
|
467
|
+
return unless step["id"] == "requirement-wayfinder"
|
|
468
|
+
return unless workflow.nil? || workflow.dig("governance", "requirement_coverage_gate").to_s == "enforced"
|
|
469
|
+
|
|
470
|
+
result = RequirementCoverage.validate(root: root, feature: state.fetch("current_feature"))
|
|
471
|
+
return if result["verdict"] == "allow"
|
|
472
|
+
|
|
473
|
+
Array(result["errors"]).each do |entry|
|
|
474
|
+
issues << issue("error", entry["code"], entry["message"], step: step["id"], coverage: entry)
|
|
475
|
+
end
|
|
476
|
+
if result["verdict"] == "await_user"
|
|
477
|
+
issues << issue("error", "wayfinder_coverage_requires_decision", "Wayfinder coverage requires user review before completion", step: step["id"], counts: result["counts"])
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
430
481
|
def validate_requirement_triage_gate(issues, step, data)
|
|
431
482
|
return unless step["id"] == "requirement-triage"
|
|
432
483
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Validates the existing workflow topology as a GateContract source. This is
|
|
5
|
+
# intentionally declarative and read-only: it does not advance STATE or alter
|
|
6
|
+
# failure-branch behavior.
|
|
7
|
+
module WorkflowGateContract
|
|
8
|
+
CHECKPOINT_FIELDS = %w[gate_after producing_step required_outputs required_meta_output required_meta_fields prompt confirm_options skip_policy resume_behavior].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def validate(workflow)
|
|
13
|
+
errors = []
|
|
14
|
+
checkpoints = workflow["checkpoints"]
|
|
15
|
+
unless checkpoints.is_a?(Hash)
|
|
16
|
+
return [error("CHECKPOINTS_SCHEMA_INVALID", "workflow checkpoints must be a mapping")]
|
|
17
|
+
end
|
|
18
|
+
checkpoints.each do |id, checkpoint|
|
|
19
|
+
unless checkpoint.is_a?(Hash)
|
|
20
|
+
errors << error("CHECKPOINT_SCHEMA_INVALID", "checkpoint must be a mapping", checkpoint: id)
|
|
21
|
+
next
|
|
22
|
+
end
|
|
23
|
+
CHECKPOINT_FIELDS.each do |field|
|
|
24
|
+
errors << error("CHECKPOINT_FIELD_MISSING", "checkpoint field is required", checkpoint: id, field: field) unless checkpoint.key?(field)
|
|
25
|
+
end
|
|
26
|
+
errors << error("CHECKPOINT_PRODUCER_MISMATCH", "gate_after must equal producing_step", checkpoint: id) if checkpoint["gate_after"].to_s != checkpoint["producing_step"].to_s
|
|
27
|
+
errors << error("CHECKPOINT_OUTPUTS_INVALID", "required_outputs must be an array", checkpoint: id) unless checkpoint["required_outputs"].is_a?(Array)
|
|
28
|
+
errors << error("CHECKPOINT_META_FIELDS_INVALID", "required_meta_fields must be an array", checkpoint: id) unless checkpoint["required_meta_fields"].is_a?(Array)
|
|
29
|
+
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]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Array(workflow.dig("governance", "enforced_checkpoints")).each do |id|
|
|
33
|
+
errors << error("CHECKPOINT_REF_NOT_FOUND", "enforced checkpoint is not defined", checkpoint: id) unless checkpoints.key?(id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Array(workflow["parallel_windows"]).each do |window|
|
|
37
|
+
errors << error("PARALLEL_WINDOW_SCHEMA_INVALID", "parallel window must define join_step and branches", window: window["id"]) unless window.is_a?(Hash) && window["join_step"] && window["branches"].is_a?(Hash)
|
|
38
|
+
next unless window.is_a?(Hash)
|
|
39
|
+
errors << error("PARALLEL_WINDOW_CHECKPOINT_INVALID", "parallel join checkpoint is not defined", window: window["id"], checkpoint: window["checkpoint"]) if window["checkpoint"] && !checkpoints.key?(window["checkpoint"])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
(workflow["failure_branches"] || {}).each do |source_step, branch|
|
|
43
|
+
resume = branch.is_a?(Hash) && branch["resume_after_success"]
|
|
44
|
+
unless resume.is_a?(Hash) && %w[flow step status checkpoint].all? { |field| resume.key?(field) }
|
|
45
|
+
errors << error("FAILURE_RETURN_TO_INVALID", "failure branch must declare flow, step, status and checkpoint", source_step: source_step)
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
errors << error("FAILURE_RETURN_CHECKPOINT_INVALID", "failure branch return checkpoint is not defined", source_step: source_step, checkpoint: resume["checkpoint"]) unless checkpoints.key?(resume["checkpoint"])
|
|
49
|
+
end
|
|
50
|
+
errors
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def error(code, message, **extra)
|
|
54
|
+
{ "code" => code, "message" => message }.merge(extra)
|
|
55
|
+
end
|
|
56
|
+
end
|
package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml
CHANGED
|
@@ -17,12 +17,15 @@ phase_scope:
|
|
|
17
17
|
# files without governance_started_revision remain legacy_unattested and are
|
|
18
18
|
# only reported until their next newly-started governed step.
|
|
19
19
|
governance:
|
|
20
|
+
gate_contract_version: 3
|
|
20
21
|
requirement_graph_gate: enforced
|
|
22
|
+
requirement_coverage_gate: enforced
|
|
21
23
|
enforced_checkpoints:
|
|
22
24
|
- CP-1
|
|
23
25
|
- CP-API
|
|
24
26
|
- CP-2
|
|
25
27
|
- CP-3
|
|
28
|
+
- CP-4
|
|
26
29
|
step_checkers:
|
|
27
30
|
requirement-baseline:
|
|
28
31
|
- requirement-graph-check
|
|
@@ -311,6 +314,7 @@ checkpoints:
|
|
|
311
314
|
producing_step: run-acceptance-tests
|
|
312
315
|
required_outputs:
|
|
313
316
|
- ".planning/test-runs/{feature}/acceptance-results.json"
|
|
317
|
+
required_meta_output: ".planning/test-runs/{feature}/acceptance-results.meta.json"
|
|
314
318
|
required_meta_fields:
|
|
315
319
|
- acceptance_tc_passed
|
|
316
320
|
- acceptance_tc_failed
|
|
@@ -365,6 +369,7 @@ steps:
|
|
|
365
369
|
order: 6
|
|
366
370
|
label: Requirement wayfinder
|
|
367
371
|
skill: workflow-wayfinder
|
|
372
|
+
engine_evidence_gate: enforced
|
|
368
373
|
depends_on:
|
|
369
374
|
- requirement-triage
|
|
370
375
|
inputs:
|
|
@@ -392,6 +397,7 @@ steps:
|
|
|
392
397
|
order: 7
|
|
393
398
|
label: Domain modeling
|
|
394
399
|
skill: workflow-domain-modeling
|
|
400
|
+
engine_evidence_gate: enforced
|
|
395
401
|
depends_on:
|
|
396
402
|
- requirement-wayfinder
|
|
397
403
|
inputs:
|
|
@@ -417,6 +423,7 @@ steps:
|
|
|
417
423
|
order: 8
|
|
418
424
|
label: Requirement feasibility review
|
|
419
425
|
skill: requirement-feasibility-review
|
|
426
|
+
engine_evidence_gate: enforced
|
|
420
427
|
depends_on:
|
|
421
428
|
- domain-modeling
|
|
422
429
|
inputs:
|
|
@@ -443,6 +450,7 @@ steps:
|
|
|
443
450
|
order: 10
|
|
444
451
|
label: Requirement baseline
|
|
445
452
|
skill: workflow-requirement-baseline
|
|
453
|
+
engine_evidence_gate: enforced
|
|
446
454
|
depends_on:
|
|
447
455
|
- requirement-feasibility-review
|
|
448
456
|
inputs:
|
|
@@ -478,6 +486,7 @@ steps:
|
|
|
478
486
|
order: 15
|
|
479
487
|
label: API contract
|
|
480
488
|
skill: workflow-api-contract
|
|
489
|
+
engine_evidence_gate: enforced
|
|
481
490
|
depends_on:
|
|
482
491
|
- requirement-baseline
|
|
483
492
|
inputs:
|
|
@@ -504,6 +513,7 @@ steps:
|
|
|
504
513
|
order: 18
|
|
505
514
|
label: Architecture feasibility
|
|
506
515
|
skill: workflow-architecture-feasibility
|
|
516
|
+
engine_evidence_gate: enforced
|
|
507
517
|
depends_on:
|
|
508
518
|
- api-contract
|
|
509
519
|
inputs:
|
|
@@ -535,6 +545,7 @@ steps:
|
|
|
535
545
|
order: 20
|
|
536
546
|
label: UI spec
|
|
537
547
|
skill: workflow-ui-spec
|
|
548
|
+
engine_evidence_gate: enforced
|
|
538
549
|
depends_on:
|
|
539
550
|
- api-contract
|
|
540
551
|
- architecture-feasibility
|
|
@@ -551,6 +562,7 @@ steps:
|
|
|
551
562
|
order: 30
|
|
552
563
|
label: Feature breakdown
|
|
553
564
|
skill: workflow-feature-breakdown
|
|
565
|
+
engine_evidence_gate: enforced
|
|
554
566
|
depends_on:
|
|
555
567
|
- ui-spec
|
|
556
568
|
inputs:
|
|
@@ -565,6 +577,7 @@ steps:
|
|
|
565
577
|
order: 40
|
|
566
578
|
label: Frontend tech spec
|
|
567
579
|
skill: workflow-frontend-tech-spec
|
|
580
|
+
engine_evidence_gate: enforced
|
|
568
581
|
depends_on:
|
|
569
582
|
- feature-breakdown
|
|
570
583
|
- api-contract
|
|
@@ -598,6 +611,7 @@ steps:
|
|
|
598
611
|
order: 50
|
|
599
612
|
label: Backend tech spec
|
|
600
613
|
skill: workflow-backend-tech-spec
|
|
614
|
+
engine_evidence_gate: enforced
|
|
601
615
|
depends_on:
|
|
602
616
|
- api-contract
|
|
603
617
|
- architecture-feasibility
|
|
@@ -625,6 +639,7 @@ steps:
|
|
|
625
639
|
order: 55
|
|
626
640
|
label: API call guide
|
|
627
641
|
skill: workflow-api-call-guide
|
|
642
|
+
engine_evidence_gate: enforced
|
|
628
643
|
depends_on:
|
|
629
644
|
- api-contract
|
|
630
645
|
- backend-tech-spec
|
|
@@ -649,6 +664,7 @@ steps:
|
|
|
649
664
|
order: 56
|
|
650
665
|
label: API supplement
|
|
651
666
|
skill: workflow-api-supplement
|
|
667
|
+
engine_evidence_gate: enforced
|
|
652
668
|
depends_on:
|
|
653
669
|
- frontend-tech-spec
|
|
654
670
|
- api-contract
|
|
@@ -672,6 +688,7 @@ steps:
|
|
|
672
688
|
order: 58
|
|
673
689
|
label: Integration verify
|
|
674
690
|
skill: workflow-integration-verify
|
|
691
|
+
engine_evidence_gate: enforced
|
|
675
692
|
depends_on:
|
|
676
693
|
- api-call-guide
|
|
677
694
|
- frontend-tech-spec
|
|
@@ -702,6 +719,7 @@ steps:
|
|
|
702
719
|
order: 60
|
|
703
720
|
label: Change plan
|
|
704
721
|
skill: workflow-change-plan
|
|
722
|
+
engine_evidence_gate: enforced
|
|
705
723
|
depends_on:
|
|
706
724
|
- acceptance-test-design
|
|
707
725
|
inputs:
|
|
@@ -735,6 +753,7 @@ steps:
|
|
|
735
753
|
order: 59
|
|
736
754
|
label: Acceptance test design
|
|
737
755
|
skill: workflow-acceptance-test-design
|
|
756
|
+
engine_evidence_gate: enforced
|
|
738
757
|
depends_on:
|
|
739
758
|
- integration-verify
|
|
740
759
|
inputs:
|
|
@@ -774,6 +793,7 @@ steps:
|
|
|
774
793
|
order: 70
|
|
775
794
|
label: Phase structure
|
|
776
795
|
action: initialize-phase-structure
|
|
796
|
+
engine_evidence_gate: enforced
|
|
777
797
|
depends_on:
|
|
778
798
|
- change-plan
|
|
779
799
|
inputs:
|
|
@@ -791,6 +811,7 @@ steps:
|
|
|
791
811
|
order: 80
|
|
792
812
|
label: Execute
|
|
793
813
|
skill: workflow-execute-phase
|
|
814
|
+
engine_evidence_gate: enforced
|
|
794
815
|
depends_on:
|
|
795
816
|
- phase-structure
|
|
796
817
|
inputs:
|
|
@@ -835,6 +856,7 @@ steps:
|
|
|
835
856
|
order: 85
|
|
836
857
|
label: Code Review
|
|
837
858
|
skill: code-review
|
|
859
|
+
engine_evidence_gate: enforced
|
|
838
860
|
depends_on:
|
|
839
861
|
- code-index-refresh
|
|
840
862
|
inputs:
|
|
@@ -849,6 +871,7 @@ steps:
|
|
|
849
871
|
order: 86
|
|
850
872
|
label: Generate mainflow cases
|
|
851
873
|
skill: workflow-generate-mainflow-cases
|
|
874
|
+
engine_evidence_gate: enforced
|
|
852
875
|
depends_on:
|
|
853
876
|
- code-index-refresh
|
|
854
877
|
inputs:
|
|
@@ -887,6 +910,7 @@ steps:
|
|
|
887
910
|
order: 88
|
|
888
911
|
label: Run frozen acceptance tests
|
|
889
912
|
skill: workflow-run-acceptance-tests
|
|
913
|
+
engine_evidence_gate: enforced
|
|
890
914
|
depends_on:
|
|
891
915
|
- code-review
|
|
892
916
|
inputs:
|
package/src/constants.ts
CHANGED