@guanlin4924/devflow-pi 0.1.16 → 0.1.18
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 +2 -0
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +297 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +3 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_assessment.rb +458 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +256 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +155 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +176 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +46 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +187 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/runtime_context_collector.rb +210 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/stale_check.rb +56 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/static_runtime_context_collector.rb +518 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +83 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +54 -2
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -2
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -1
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +37 -0
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/agents/openai.yaml +4 -0
- package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +7 -2
- package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Core-derived human view of graph facts. This renderer owns neither the
|
|
5
|
+
# workload/decision truth nor a free-text assessment: delete it and the same
|
|
6
|
+
# inputs reproduce it. It deliberately does not write a selection on behalf of
|
|
7
|
+
# an agent.
|
|
8
|
+
require "digest"
|
|
9
|
+
require "json"
|
|
10
|
+
require "optparse"
|
|
11
|
+
require_relative "architecture_feasibility"
|
|
12
|
+
require_relative "control_io"
|
|
13
|
+
|
|
14
|
+
module ArchitectureBrief
|
|
15
|
+
ROOT = ".planning/features"
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def paths(root, feature)
|
|
21
|
+
raise ArgumentError, "invalid feature" unless RequirementGraph.valid_feature?(feature)
|
|
22
|
+
|
|
23
|
+
directory = File.expand_path(File.join(ROOT, feature), root)
|
|
24
|
+
root_real = File.realpath(root)
|
|
25
|
+
raise ArgumentError, "architecture brief path escapes project root" unless directory.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
26
|
+
[directory, File.join(directory, "architecture-brief.md"), File.join(directory, "architecture-brief.meta.json")]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def read_meta(path)
|
|
30
|
+
return { "brief_revision" => 0 } unless File.file?(path)
|
|
31
|
+
|
|
32
|
+
data = JSON.parse(File.read(path))
|
|
33
|
+
raise ArgumentError, "architecture brief meta is invalid" unless data.is_a?(Hash) && data["brief_revision"].is_a?(Integer)
|
|
34
|
+
|
|
35
|
+
data
|
|
36
|
+
rescue JSON::ParserError
|
|
37
|
+
raise ArgumentError, "architecture brief meta is invalid"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def text(value)
|
|
41
|
+
value.to_s.gsub("|", "\\|").gsub("\n", " ")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def connected_architecture_decisions(graph)
|
|
45
|
+
decisions = Array(graph["nodes"]).select { |node| RequirementGraph.architecture_option_decision?(node) }
|
|
46
|
+
decisions.select do |decision|
|
|
47
|
+
Array(graph["edges"]).any? do |edge|
|
|
48
|
+
edge.is_a?(Hash) && edge["to"] == decision["id"] && %w[informs_decision constrains_decision].include?(edge["relation"])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def connected_architecture_risks(graph)
|
|
54
|
+
risks = Array(graph["nodes"]).select { |node| RequirementGraph.architecture_option_risk?(node) }
|
|
55
|
+
risks.select do |risk|
|
|
56
|
+
Array(graph["edges"]).any? do |edge|
|
|
57
|
+
edge.is_a?(Hash) && edge["to"] == risk["id"] && edge["relation"] == "has_architecture_risk"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def comparison_status(assessment, decisions, risks)
|
|
63
|
+
return "not_required" if assessment["level"] == "not_triggered"
|
|
64
|
+
return "missing" if decisions.empty?
|
|
65
|
+
return "awaiting_user_confirmation" if decisions.any? { |decision| decision["status"] != "confirmed" }
|
|
66
|
+
if assessment["level"] == "full"
|
|
67
|
+
return "missing_risk" if risks.empty?
|
|
68
|
+
return "awaiting_risk_confirmation" if risks.any? { |risk| risk["status"] != "confirmed" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
"confirmed"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_markdown(graph, assessment, decisions, risks, comparison)
|
|
75
|
+
workload_shapes = Array(graph["nodes"]).select { |node| node["kind"] == "workload_shape" }
|
|
76
|
+
constraints = Array(graph["nodes"]).select { |node| node["kind"] == "workload_constraint" }
|
|
77
|
+
lines = [
|
|
78
|
+
"# 架构评估简报:#{graph["feature"]}",
|
|
79
|
+
"",
|
|
80
|
+
"> 本文由 Core 从需求图谱和静态上下文快照派生。它不是生产拓扑、实时容量或监控结论;删除后可用同一 revision 重建。",
|
|
81
|
+
"",
|
|
82
|
+
"## 固定输入",
|
|
83
|
+
"",
|
|
84
|
+
"- 需求图谱:schema v#{graph["schema_version"]} / revision #{graph["revision"]}",
|
|
85
|
+
"- 静态上下文:#{assessment.dig("static_context", "available") ? "snapshot #{assessment.dig("static_context", "snapshot_revision")}(#{assessment.dig("static_context", "path")})" : "缺失或无效"}",
|
|
86
|
+
"- 推导状态:#{assessment["assessment_status"]}",
|
|
87
|
+
"- 评估级别:#{assessment["level"]}",
|
|
88
|
+
"- 方案比较状态:#{comparison}",
|
|
89
|
+
"",
|
|
90
|
+
"## 触发事实",
|
|
91
|
+
""
|
|
92
|
+
]
|
|
93
|
+
if assessment["triggers"].empty?
|
|
94
|
+
lines << "- 未从已确认的 P2 工作负载节点观察到触发项。此结论不等于不存在所有架构风险。"
|
|
95
|
+
else
|
|
96
|
+
assessment["triggers"].each { |trigger| lines << "- `#{trigger["code"]}` — #{trigger["node_id"]}" }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
lines.concat(["", "## 工作负载形态", "", "| 节点 | 状态 | 执行 | 操作 | 扇出 | 数据影响 | 外部调用 |", "|---|---|---|---|---|---|---|"])
|
|
100
|
+
if workload_shapes.empty?
|
|
101
|
+
lines << "| — | — | — | — | — | — | — |"
|
|
102
|
+
else
|
|
103
|
+
workload_shapes.each do |node|
|
|
104
|
+
facts = node.fetch("data", {}).fetch("facts", {})
|
|
105
|
+
lines << "| #{node["id"]} | #{node["status"]} | #{text(facts["execution"])} | #{text(facts["operation"])} | #{text(facts["fanout"])} | #{text(facts["data_effect"])} | #{text(Array(facts["external_calls"]).join(", "))} |"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
lines.concat(["", "## 工作负载约束", "", "| 节点 | 状态 | 指标 | 来源状态 | 目标或缺口 |", "|---|---|---|---|---|"])
|
|
110
|
+
if constraints.empty?
|
|
111
|
+
lines << "| — | — | — | — | — |"
|
|
112
|
+
else
|
|
113
|
+
constraints.each do |node|
|
|
114
|
+
data = node.fetch("data", {})
|
|
115
|
+
target = if data["source_status"] == "unknown"
|
|
116
|
+
unknown = data.fetch("unknown", {})
|
|
117
|
+
"unknown: #{text(unknown["reason"])}(#{text(unknown["required_before"])} 前处理)"
|
|
118
|
+
else
|
|
119
|
+
value = data.fetch("target", {})
|
|
120
|
+
"#{text(value["value"])} #{text(value["unit"])} / #{text(value["window"])}"
|
|
121
|
+
end
|
|
122
|
+
lines << "| #{node["id"]} | #{node["status"]} | #{text(data["metric"])} | #{text(data["source_status"])} | #{target} |"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
lines.concat(["", "## 方案比较(DECISION 派生)", ""])
|
|
127
|
+
if decisions.empty?
|
|
128
|
+
lines << "- 尚无与工作负载事实连接的架构方案比较 DECISION。"
|
|
129
|
+
else
|
|
130
|
+
decisions.each do |decision|
|
|
131
|
+
comparison_data = decision.dig("data", "comparison") || {}
|
|
132
|
+
lines << "### #{decision["id"]}(#{decision["status"]})"
|
|
133
|
+
lines << ""
|
|
134
|
+
lines << "- 模式:#{comparison_data["mode"]}"
|
|
135
|
+
lines << "- 用户确认的推荐:#{comparison_data["recommendation_option_id"]}" if decision["status"] == "confirmed"
|
|
136
|
+
lines << "- 候选推荐:#{comparison_data["recommendation_option_id"]}" unless decision["status"] == "confirmed"
|
|
137
|
+
lines << ""
|
|
138
|
+
lines << "| 选项 | 机制 | 事实引用 | 放弃/代价 |"
|
|
139
|
+
lines << "|---|---|---|---|"
|
|
140
|
+
Array(comparison_data["options"]).each do |option|
|
|
141
|
+
lines << "| #{text(option["option_key"])} | #{text(option["mechanism"])} | #{text(Array(option["fact_refs"]).join(", "))} | #{text(Array(option["tradeoffs"]).join(";"))} |"
|
|
142
|
+
end
|
|
143
|
+
lines << ""
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
lines.concat(["## 架构风险(RISK 派生)", "", "| 节点 | 状态 | 失败模式 | 影响 | 处理 | 负责人 / 复核条件 |", "|---|---|---|---|---|---|"])
|
|
148
|
+
if risks.empty?
|
|
149
|
+
lines << "| — | — | — | — | — | — |"
|
|
150
|
+
else
|
|
151
|
+
risks.each do |risk|
|
|
152
|
+
data = risk.fetch("data", {})
|
|
153
|
+
lines << "| #{risk["id"]} | #{risk["status"]} | #{text(data["failure_mode"])} | #{text(data["impact"])} | #{text(data["treatment"])}: #{text(data["mitigation"])} | #{text(data["owner"])} / #{text(data["review_condition"])} |"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
lines.concat(["## 未决项", ""])
|
|
158
|
+
if assessment["unresolved"].empty?
|
|
159
|
+
lines << "- 无 Core 推导的未决输入。"
|
|
160
|
+
else
|
|
161
|
+
assessment["unresolved"].each do |item|
|
|
162
|
+
suffix = item.reject { |key, _| key == "code" }.map { |key, value| "#{key}=#{text(value)}" }.join(";")
|
|
163
|
+
lines << "- `#{item["code"]}`#{suffix.empty? ? "" : " — #{suffix}"}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
lines.concat(["", "## 边界", ""] + assessment["limitations"].map { |item| "- #{item}" })
|
|
168
|
+
lines.join("\n") + "\n"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def build(root, feature)
|
|
172
|
+
graph, = RequirementGraph.load_graph(root, feature)
|
|
173
|
+
assessment = ArchitectureFeasibility.assessment(graph, ArchitectureFeasibility.safe_static_context(root))
|
|
174
|
+
decisions = connected_architecture_decisions(graph)
|
|
175
|
+
risks = connected_architecture_risks(graph)
|
|
176
|
+
comparison = comparison_status(assessment, decisions, risks)
|
|
177
|
+
markdown = render_markdown(graph, assessment, decisions, risks, comparison)
|
|
178
|
+
source = {
|
|
179
|
+
"graph_revision" => graph["revision"],
|
|
180
|
+
"graph_schema_version" => graph["schema_version"],
|
|
181
|
+
"static_context_snapshot_revision" => assessment.dig("static_context", "snapshot_revision"),
|
|
182
|
+
"assessment_status" => assessment["assessment_status"],
|
|
183
|
+
"assessment_level" => assessment["level"],
|
|
184
|
+
"trigger_codes" => assessment["triggers"].map { |item| item["code"] },
|
|
185
|
+
"unresolved_codes" => assessment["unresolved"].map { |item| item["code"] },
|
|
186
|
+
"comparison_status" => comparison,
|
|
187
|
+
"decision_ids" => decisions.map { |node| node["id"] },
|
|
188
|
+
"confirmed_decision_ids" => decisions.select { |node| node["status"] == "confirmed" }.map { |node| node["id"] },
|
|
189
|
+
"risk_ids" => risks.map { |node| node["id"] },
|
|
190
|
+
"confirmed_risk_ids" => risks.select { |node| node["status"] == "confirmed" }.map { |node| node["id"] }
|
|
191
|
+
}
|
|
192
|
+
[graph, assessment, source, markdown]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def inspect(root, feature)
|
|
196
|
+
_directory, _markdown, meta = paths(root, feature)
|
|
197
|
+
current = read_meta(meta)
|
|
198
|
+
{ "ok" => true, "brief_revision" => current["brief_revision"], "meta" => current }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def write(root, feature, expected_revision)
|
|
202
|
+
directory, markdown_path, meta_path = paths(root, feature)
|
|
203
|
+
existing = read_meta(meta_path)
|
|
204
|
+
actual = existing["brief_revision"]
|
|
205
|
+
return { "ok" => false, "reason" => "stale_brief_revision", "expected_revision" => expected_revision, "actual_revision" => actual } unless actual == expected_revision
|
|
206
|
+
|
|
207
|
+
_graph, _assessment, source, markdown = build(root, feature)
|
|
208
|
+
source_fingerprint = Digest::SHA256.hexdigest(JSON.generate(source))
|
|
209
|
+
if existing["source_fingerprint"] == source_fingerprint && File.file?(markdown_path)
|
|
210
|
+
return { "ok" => true, "reused" => true, "brief_revision" => actual, "path" => File.join(ROOT, feature, "architecture-brief.md"), "meta_path" => File.join(ROOT, feature, "architecture-brief.meta.json"), "meta" => existing }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
revision = actual + 1
|
|
214
|
+
meta = source.merge(
|
|
215
|
+
"schema_version" => SCHEMA_VERSION,
|
|
216
|
+
"origin" => "core_architecture_brief",
|
|
217
|
+
"feature" => feature,
|
|
218
|
+
"brief_revision" => revision,
|
|
219
|
+
"source_fingerprint" => source_fingerprint,
|
|
220
|
+
"content_sha256" => Digest::SHA256.hexdigest(markdown)
|
|
221
|
+
)
|
|
222
|
+
DevflowControlIO.atomic_write(markdown_path, markdown)
|
|
223
|
+
DevflowControlIO.atomic_write(meta_path, JSON.pretty_generate(meta) + "\n")
|
|
224
|
+
{ "ok" => true, "reused" => false, "brief_revision" => revision, "path" => File.join(ROOT, feature, "architecture-brief.md"), "meta_path" => File.join(ROOT, feature, "architecture-brief.meta.json"), "meta" => meta }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
if __FILE__ == $PROGRAM_NAME
|
|
229
|
+
options = { feature: nil, expected_brief_revision: nil, write: false }
|
|
230
|
+
command = ARGV.shift
|
|
231
|
+
OptionParser.new do |opts|
|
|
232
|
+
opts.banner = "Usage: architecture_brief.rb inspect|render --feature NAME [options]"
|
|
233
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
234
|
+
opts.on("--expected-brief-revision N", Integer) { |value| options[:expected_brief_revision] = value }
|
|
235
|
+
opts.on("--write") { options[:write] = true }
|
|
236
|
+
opts.on("--json") {}
|
|
237
|
+
end.parse!
|
|
238
|
+
root = Dir.pwd
|
|
239
|
+
lock = command == "render" && options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
240
|
+
begin
|
|
241
|
+
result = case command
|
|
242
|
+
when "inspect" then ArchitectureBrief.inspect(root, options[:feature])
|
|
243
|
+
when "render"
|
|
244
|
+
raise ArgumentError, "--write is required" unless options[:write]
|
|
245
|
+
raise ArgumentError, "--expected-brief-revision is required" if options[:expected_brief_revision].nil?
|
|
246
|
+
ArchitectureBrief.write(root, options[:feature], options[:expected_brief_revision])
|
|
247
|
+
else
|
|
248
|
+
{ "ok" => false, "reason" => "invalid_command", "message" => "command must be inspect or render" }
|
|
249
|
+
end
|
|
250
|
+
rescue ArgumentError => error
|
|
251
|
+
result = { "ok" => false, "reason" => "architecture_brief_failed", "message" => error.message }
|
|
252
|
+
ensure
|
|
253
|
+
DevflowControlIO.release_lock(lock)
|
|
254
|
+
end
|
|
255
|
+
puts JSON.pretty_generate(result)
|
|
256
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# P2 candidate-only validator. It deliberately validates only the shapes an
|
|
5
|
+
# agent may propose; confirmation, persistence, ID assignment, and gate
|
|
6
|
+
# decisions remain Core responsibilities in later slices.
|
|
7
|
+
require "json"
|
|
8
|
+
require "optparse"
|
|
9
|
+
require "time"
|
|
10
|
+
|
|
11
|
+
module ArchitectureConstraints
|
|
12
|
+
EXECUTIONS = %w[sync async scheduled].freeze
|
|
13
|
+
OPERATIONS = %w[read write batch_read batch_write].freeze
|
|
14
|
+
FANOUTS = %w[none bounded unbounded unknown].freeze
|
|
15
|
+
DATA_EFFECTS = %w[none update append_only migration unknown].freeze
|
|
16
|
+
METRICS = %w[peak_qps data_growth p95_latency rpo rto].freeze
|
|
17
|
+
SOURCE_STATUSES = %w[measured contracted approved_assumption unknown].freeze
|
|
18
|
+
EVIDENCE_KINDS = %w[source_file cp_record external_measurement].freeze
|
|
19
|
+
ASSUMPTION_FIELDS = %w[owner observability_metric review_date escalation_trigger].freeze
|
|
20
|
+
UNKNOWN_FIELDS = %w[reason resolution_owner required_before].freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def present?(value)
|
|
25
|
+
!value.to_s.strip.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reference?(value, prefixes)
|
|
29
|
+
value.to_s.match?(Regexp.new("\\A(?:#{prefixes.join("|")})-\\d+\\z"))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def errors_for_enum(errors, object, key, values)
|
|
33
|
+
value = object[key].to_s
|
|
34
|
+
errors << "#{key} is required" if value.empty?
|
|
35
|
+
errors << "invalid #{key}" if !value.empty? && !values.include?(value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The public CLI is deliberately candidate-only. Core-owned persistence
|
|
39
|
+
# needs to revalidate the same shape after it adds its own confirmation
|
|
40
|
+
# envelope, so it can opt into accepting the graph-owned status field.
|
|
41
|
+
def validate_shape(shape, allow_confirmed: false)
|
|
42
|
+
errors = []
|
|
43
|
+
errors << "feature is required" unless present?(shape["feature"])
|
|
44
|
+
refs = shape["behavior_refs"]
|
|
45
|
+
errors << "behavior_refs must be a non-empty array" unless refs.is_a?(Array) && !refs.empty?
|
|
46
|
+
Array(refs).each { |ref| errors << "behavior_refs must reference BEH" unless reference?(ref, ["BEH"]) }
|
|
47
|
+
errors_for_enum(errors, shape.fetch("facts", {}), "execution", EXECUTIONS) if shape["facts"].is_a?(Hash)
|
|
48
|
+
errors << "facts is required" unless shape["facts"].is_a?(Hash)
|
|
49
|
+
facts = shape["facts"].is_a?(Hash) ? shape["facts"] : {}
|
|
50
|
+
errors_for_enum(errors, facts, "operation", OPERATIONS)
|
|
51
|
+
errors_for_enum(errors, facts, "fanout", FANOUTS)
|
|
52
|
+
errors_for_enum(errors, facts, "data_effect", DATA_EFFECTS)
|
|
53
|
+
external_calls = facts["external_calls"]
|
|
54
|
+
errors << "external_calls must be an array" unless external_calls.nil? || external_calls.is_a?(Array)
|
|
55
|
+
source_refs = shape["source_refs"]
|
|
56
|
+
errors << "source_refs must be a non-empty array" unless source_refs.is_a?(Array) && !source_refs.empty?
|
|
57
|
+
Array(source_refs).each { |ref| errors << "source_refs must reference REQ/BEH/API" unless reference?(ref, %w[REQ BEH API]) }
|
|
58
|
+
allowed_statuses = allow_confirmed ? %w[candidate confirmed] : %w[candidate]
|
|
59
|
+
errors << "candidate shapes cannot set status confirmed" if !allow_confirmed && shape["status"].to_s == "confirmed"
|
|
60
|
+
errors << "invalid status" if present?(shape["status"]) && !allowed_statuses.include?(shape["status"])
|
|
61
|
+
errors.uniq
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def valid_target?(target)
|
|
65
|
+
target.is_a?(Hash) && target["value"].is_a?(Numeric) && target["value"].positive? && present?(target["unit"]) && present?(target["window"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_evidence(errors, evidence)
|
|
69
|
+
unless evidence.is_a?(Hash)
|
|
70
|
+
errors << "evidence is required"
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
errors_for_enum(errors, evidence, "kind", EVIDENCE_KINDS)
|
|
74
|
+
errors << "evidence.ref is required" unless present?(evidence["ref"])
|
|
75
|
+
return unless evidence["kind"] == "source_file"
|
|
76
|
+
|
|
77
|
+
errors << "source_file evidence.git_revision is required" unless present?(evidence["git_revision"])
|
|
78
|
+
errors << "source_file evidence.sha256 must be a SHA-256 hex digest" unless evidence["sha256"].to_s.match?(/\A[0-9a-f]{64}\z/i)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_assumption(errors, assumption)
|
|
82
|
+
unless assumption.is_a?(Hash)
|
|
83
|
+
errors << "assumption is required"
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
ASSUMPTION_FIELDS.each { |field| errors << "approved_assumption requires #{field}" unless present?(assumption[field]) }
|
|
87
|
+
Time.iso8601(assumption["review_date"].to_s) if present?(assumption["review_date"])
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
errors << "approved_assumption review_date must be ISO-8601"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_unknown(errors, unknown)
|
|
93
|
+
unless unknown.is_a?(Hash)
|
|
94
|
+
errors << "unknown is required"
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
UNKNOWN_FIELDS.each { |field| errors << "unknown requires #{field}" unless present?(unknown[field]) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_constraint(constraint, allow_confirmed: false)
|
|
101
|
+
errors = []
|
|
102
|
+
errors << "feature is required" unless present?(constraint["feature"])
|
|
103
|
+
errors << "workload_ref must reference BEH/SL/API" unless reference?(constraint["workload_ref"], %w[BEH SL API])
|
|
104
|
+
errors_for_enum(errors, constraint, "metric", METRICS)
|
|
105
|
+
errors_for_enum(errors, constraint, "source_status", SOURCE_STATUSES)
|
|
106
|
+
allowed_statuses = allow_confirmed ? %w[candidate confirmed] : %w[candidate]
|
|
107
|
+
errors << "candidate constraints cannot set status confirmed" if !allow_confirmed && constraint["status"].to_s == "confirmed"
|
|
108
|
+
errors << "invalid status" if present?(constraint["status"]) && !allowed_statuses.include?(constraint["status"])
|
|
109
|
+
|
|
110
|
+
case constraint["source_status"]
|
|
111
|
+
when "measured", "contracted"
|
|
112
|
+
errors << "target requires positive numeric value, unit, and window" unless valid_target?(constraint["target"])
|
|
113
|
+
validate_evidence(errors, constraint["evidence"])
|
|
114
|
+
errors << "unknown is only valid for source_status unknown" if constraint.key?("unknown")
|
|
115
|
+
when "approved_assumption"
|
|
116
|
+
errors << "target requires positive numeric value, unit, and window" unless valid_target?(constraint["target"])
|
|
117
|
+
validate_assumption(errors, constraint["assumption"])
|
|
118
|
+
errors << "unknown is only valid for source_status unknown" if constraint.key?("unknown")
|
|
119
|
+
when "unknown"
|
|
120
|
+
errors << "unknown constraints must not contain target" if constraint.key?("target")
|
|
121
|
+
errors << "unknown constraints must not contain evidence" if constraint.key?("evidence")
|
|
122
|
+
errors << "unknown constraints must not contain assumption" if constraint.key?("assumption")
|
|
123
|
+
validate_unknown(errors, constraint["unknown"])
|
|
124
|
+
end
|
|
125
|
+
errors.uniq
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if __FILE__ == $PROGRAM_NAME
|
|
130
|
+
options = { shape_json: nil, constraint_json: nil }
|
|
131
|
+
command = ARGV.shift
|
|
132
|
+
OptionParser.new do |o|
|
|
133
|
+
o.on("--shape-json JSON") { |value| options[:shape_json] = value }
|
|
134
|
+
o.on("--constraint-json JSON") { |value| options[:constraint_json] = value }
|
|
135
|
+
o.on("--json") {}
|
|
136
|
+
end.parse!
|
|
137
|
+
begin
|
|
138
|
+
result =
|
|
139
|
+
case command
|
|
140
|
+
when "validate-shape"
|
|
141
|
+
raise ArgumentError, "--shape-json is required" unless options[:shape_json]
|
|
142
|
+
errors = ArchitectureConstraints.validate_shape(JSON.parse(options[:shape_json]))
|
|
143
|
+
{ "ok" => errors.empty?, "errors" => errors }
|
|
144
|
+
when "validate-constraint"
|
|
145
|
+
raise ArgumentError, "--constraint-json is required" unless options[:constraint_json]
|
|
146
|
+
errors = ArchitectureConstraints.validate_constraint(JSON.parse(options[:constraint_json]))
|
|
147
|
+
{ "ok" => errors.empty?, "errors" => errors }
|
|
148
|
+
else
|
|
149
|
+
raise ArgumentError, "command must be validate-shape or validate-constraint"
|
|
150
|
+
end
|
|
151
|
+
rescue StandardError => error
|
|
152
|
+
result = { "ok" => false, "reason" => "architecture_constraints_failed", "message" => error.message }
|
|
153
|
+
end
|
|
154
|
+
puts JSON.pretty_generate(result)
|
|
155
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Read-only P2 trigger derivation. It never claims a repository declaration is
|
|
5
|
+
# production capacity, never chooses an option, and never persists a decision.
|
|
6
|
+
# Its sole job is to turn already-confirmed graph facts into a reproducible
|
|
7
|
+
# assessment level and an honest list of unresolved inputs for the next skill.
|
|
8
|
+
require "json"
|
|
9
|
+
require "optparse"
|
|
10
|
+
require "pathname"
|
|
11
|
+
require_relative "requirement_graph"
|
|
12
|
+
require_relative "static_runtime_context_collector"
|
|
13
|
+
|
|
14
|
+
module ArchitectureFeasibility
|
|
15
|
+
LEVELS = { "not_triggered" => 0, "lightweight" => 1, "full" => 2 }.freeze
|
|
16
|
+
STATIC_POINTER = StaticRuntimeContextCollector::POINTER
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def promote(level, candidate)
|
|
21
|
+
LEVELS.fetch(candidate) > LEVELS.fetch(level) ? candidate : level
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def safe_static_context(root)
|
|
25
|
+
pointer_path = File.join(root, STATIC_POINTER)
|
|
26
|
+
return { "available" => false, "code" => "ARC_STATIC_CONTEXT_MISSING" } unless File.file?(pointer_path)
|
|
27
|
+
|
|
28
|
+
pointer = JSON.parse(File.read(pointer_path))
|
|
29
|
+
relative = pointer.fetch("path")
|
|
30
|
+
raise ArgumentError, "static runtime-context pointer path is invalid" unless relative.is_a?(String) && relative.start_with?(".context/static-runtime-context/revisions/")
|
|
31
|
+
|
|
32
|
+
absolute = File.expand_path(relative, root)
|
|
33
|
+
root_real = File.realpath(root)
|
|
34
|
+
raise ArgumentError, "static runtime-context pointer escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
35
|
+
|
|
36
|
+
snapshot = JSON.parse(File.read(absolute))
|
|
37
|
+
revision = snapshot["snapshot_revision"]
|
|
38
|
+
raise ArgumentError, "static runtime-context snapshot revision is invalid" unless revision.is_a?(Integer) && revision.positive?
|
|
39
|
+
raise ArgumentError, "static runtime-context pointer revision does not match snapshot" unless pointer["current_revision"] == revision
|
|
40
|
+
raise ArgumentError, "static runtime-context snapshot schema is unsupported" unless snapshot["schema_version"] == StaticRuntimeContextCollector::SCHEMA_VERSION
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
"available" => true,
|
|
44
|
+
"path" => relative,
|
|
45
|
+
"snapshot_revision" => revision,
|
|
46
|
+
"repository_revision" => snapshot["repository_revision"],
|
|
47
|
+
"coverage" => Array(snapshot["coverage"]).map { |item| item.slice("subject_class", "status", "rule") }
|
|
48
|
+
}
|
|
49
|
+
rescue JSON::ParserError, KeyError => error
|
|
50
|
+
{ "available" => false, "code" => "ARC_STATIC_CONTEXT_INVALID", "detail" => error.message }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def valid_confirmed_workload?(graph, node)
|
|
54
|
+
node["status"] == "confirmed" && RequirementGraph.workload_node_errors(
|
|
55
|
+
graph, node, require_edges: true, require_confirmed_inputs: true
|
|
56
|
+
).empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def assessment(graph, static_context)
|
|
60
|
+
level = "not_triggered"
|
|
61
|
+
triggers = []
|
|
62
|
+
unresolved = []
|
|
63
|
+
graph_errors = RequirementGraph.validate(graph)
|
|
64
|
+
Array(graph["nodes"]).each do |node|
|
|
65
|
+
next unless node.is_a?(Hash) && %w[workload_shape workload_constraint].include?(node["kind"])
|
|
66
|
+
|
|
67
|
+
if node["status"] != "confirmed"
|
|
68
|
+
level = promote(level, "lightweight")
|
|
69
|
+
unresolved << { "code" => "ARC_WORKLOAD_CANDIDATE_UNCONFIRMED", "node_id" => node["id"] }
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
unless valid_confirmed_workload?(graph, node)
|
|
73
|
+
level = promote(level, "lightweight")
|
|
74
|
+
unresolved << { "code" => "ARC_WORKLOAD_CONFIRMED_BUT_INVALID", "node_id" => node["id"] }
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
data = node.fetch("data")
|
|
79
|
+
if node["kind"] == "workload_shape"
|
|
80
|
+
facts = data.fetch("facts")
|
|
81
|
+
case facts["data_effect"]
|
|
82
|
+
when "migration"
|
|
83
|
+
level = promote(level, "full")
|
|
84
|
+
triggers << { "code" => "ARC_DATA_MIGRATION", "node_id" => node["id"] }
|
|
85
|
+
end
|
|
86
|
+
case facts["fanout"]
|
|
87
|
+
when "unbounded"
|
|
88
|
+
level = promote(level, "full")
|
|
89
|
+
triggers << { "code" => "ARC_UNBOUNDED_FANOUT", "node_id" => node["id"] }
|
|
90
|
+
when "bounded"
|
|
91
|
+
level = promote(level, "lightweight")
|
|
92
|
+
triggers << { "code" => "ARC_BOUNDED_FANOUT", "node_id" => node["id"] }
|
|
93
|
+
when "unknown"
|
|
94
|
+
level = promote(level, "lightweight")
|
|
95
|
+
unresolved << { "code" => "ARC_FANOUT_UNKNOWN", "node_id" => node["id"] }
|
|
96
|
+
end
|
|
97
|
+
case facts["execution"]
|
|
98
|
+
when "async"
|
|
99
|
+
level = promote(level, "lightweight")
|
|
100
|
+
triggers << { "code" => "ARC_ASYNC_EXECUTION", "node_id" => node["id"] }
|
|
101
|
+
when "scheduled"
|
|
102
|
+
level = promote(level, "lightweight")
|
|
103
|
+
triggers << { "code" => "ARC_SCHEDULED_EXECUTION", "node_id" => node["id"] }
|
|
104
|
+
end
|
|
105
|
+
if %w[batch_read batch_write].include?(facts["operation"])
|
|
106
|
+
level = promote(level, "lightweight")
|
|
107
|
+
triggers << { "code" => "ARC_BATCH_OPERATION", "node_id" => node["id"] }
|
|
108
|
+
end
|
|
109
|
+
if Array(facts["external_calls"]).any?
|
|
110
|
+
level = promote(level, "lightweight")
|
|
111
|
+
triggers << { "code" => "ARC_EXTERNAL_CALL", "node_id" => node["id"] }
|
|
112
|
+
end
|
|
113
|
+
else
|
|
114
|
+
if data["source_status"] == "unknown"
|
|
115
|
+
level = promote(level, "lightweight")
|
|
116
|
+
unresolved << { "code" => "ARC_CONSTRAINT_UNKNOWN", "node_id" => node["id"], "required_before" => data.dig("unknown", "required_before") }
|
|
117
|
+
else
|
|
118
|
+
level = promote(level, "lightweight")
|
|
119
|
+
triggers << { "code" => "ARC_WORKLOAD_CONSTRAINT", "node_id" => node["id"], "metric" => data["metric"], "source_status" => data["source_status"] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
unless static_context["available"]
|
|
125
|
+
unresolved << { "code" => static_context["code"], "detail" => static_context["detail"] }.compact
|
|
126
|
+
end
|
|
127
|
+
workload_errors = graph_errors.grep(/\A(?:WSH|WLC)-/)
|
|
128
|
+
unresolved << { "code" => "ARC_WORKLOAD_GRAPH_INVALID", "count" => workload_errors.length } if workload_errors.any?
|
|
129
|
+
|
|
130
|
+
# P2 deliberately uses no numeric threshold. A numeric constraint is a
|
|
131
|
+
# reason to compare alternatives, not evidence that a made-up "high load"
|
|
132
|
+
# threshold has been crossed.
|
|
133
|
+
status = unresolved.empty? ? "ready" : "incomplete"
|
|
134
|
+
required_follow_up =
|
|
135
|
+
case level
|
|
136
|
+
when "full" then %w[option_comparison decision risk]
|
|
137
|
+
when "lightweight" then %w[option_comparison]
|
|
138
|
+
else []
|
|
139
|
+
end
|
|
140
|
+
{
|
|
141
|
+
"ok" => true,
|
|
142
|
+
"origin" => "core_architecture_feasibility",
|
|
143
|
+
"feature" => graph["feature"],
|
|
144
|
+
"graph_revision" => graph["revision"],
|
|
145
|
+
"graph_schema_version" => graph["schema_version"],
|
|
146
|
+
"static_context" => static_context,
|
|
147
|
+
"assessment_status" => status,
|
|
148
|
+
"level" => level,
|
|
149
|
+
"triggers" => triggers.uniq,
|
|
150
|
+
"unresolved" => unresolved.uniq,
|
|
151
|
+
"required_follow_up" => required_follow_up,
|
|
152
|
+
"limitations" => [
|
|
153
|
+
"Static context records repository declarations/references only; it is not production topology, health, or capacity.",
|
|
154
|
+
"This derivation does not select an implementation option or accept a risk.",
|
|
155
|
+
"P0 dependency-delta assessment remains a separate input until the combined evaluator is implemented."
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if __FILE__ == $PROGRAM_NAME
|
|
162
|
+
options = { feature: nil }
|
|
163
|
+
OptionParser.new do |opts|
|
|
164
|
+
opts.banner = "Usage: architecture_feasibility.rb assess --feature NAME --json"
|
|
165
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
166
|
+
opts.on("--json") {}
|
|
167
|
+
end.parse!
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
root = Dir.pwd
|
|
171
|
+
graph, = RequirementGraph.load_graph(root, options[:feature])
|
|
172
|
+
puts JSON.pretty_generate(ArchitectureFeasibility.assessment(graph, ArchitectureFeasibility.safe_static_context(root)))
|
|
173
|
+
rescue ArgumentError => error
|
|
174
|
+
puts JSON.pretty_generate({ "ok" => false, "reason" => "architecture_feasibility_failed", "message" => error.message })
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "json"
|
|
5
6
|
require "pathname"
|
|
6
7
|
require "securerandom"
|
|
7
8
|
|
|
@@ -81,4 +82,49 @@ module DevflowControlIO
|
|
|
81
82
|
ensure
|
|
82
83
|
File.delete(temporary) if temporary && File.exist?(temporary)
|
|
83
84
|
end
|
|
85
|
+
|
|
86
|
+
# Create-once counterpart to atomic_write. Control artifacts that represent
|
|
87
|
+
# a human confirmation or a historical revision must never be silently
|
|
88
|
+
# replaced. The hard-link publish step is atomic and fails when the final
|
|
89
|
+
# name already exists.
|
|
90
|
+
def atomic_create(path, content)
|
|
91
|
+
directory = File.dirname(path)
|
|
92
|
+
FileUtils.mkdir_p(directory)
|
|
93
|
+
temporary = File.join(directory, ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
|
|
94
|
+
|
|
95
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
96
|
+
file.write(content)
|
|
97
|
+
file.flush
|
|
98
|
+
file.fsync
|
|
99
|
+
end
|
|
100
|
+
File.link(temporary, path)
|
|
101
|
+
rescue Errno::EEXIST
|
|
102
|
+
raise InvalidControlPath, "refusing to overwrite immutable control artifact: #{path}"
|
|
103
|
+
ensure
|
|
104
|
+
File.delete(temporary) if temporary && File.exist?(temporary)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Callers hold the project lock while appending. JSONL keeps a durable,
|
|
108
|
+
# append-only audit trail without turning the current pointer into history.
|
|
109
|
+
def append_audit(path, entry)
|
|
110
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
111
|
+
File.open(path, File::WRONLY | File::CREAT | File::APPEND, 0o644) do |file|
|
|
112
|
+
file.write(JSON.generate(entry) + "\n")
|
|
113
|
+
file.flush
|
|
114
|
+
file.fsync
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# 独立受控工件登记:与 sync_artifacts 的 workflow-outputs registry 分开,
|
|
119
|
+
# 避免被 workflow 重建覆盖。按 path 去重,last-write-wins。
|
|
120
|
+
def register_control_artifact(root, entry)
|
|
121
|
+
path = File.join(root, ".planning", "artifacts", "control-artifacts.json")
|
|
122
|
+
existing = File.exist?(path) ? JSON.parse(File.read(path)) : { "schema_version" => 1, "artifacts" => [] }
|
|
123
|
+
existing["artifacts"] ||= []
|
|
124
|
+
existing["artifacts"].reject! { |item| item["path"] == entry["path"] }
|
|
125
|
+
existing["artifacts"] << entry
|
|
126
|
+
atomic_write(path, JSON.pretty_generate(existing) + "\n")
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
atomic_write(path, JSON.pretty_generate({ "schema_version" => 1, "artifacts" => [entry] }) + "\n")
|
|
129
|
+
end
|
|
84
130
|
end
|
|
@@ -13,6 +13,7 @@ require_relative "requirement_graph"
|
|
|
13
13
|
POLICY = {
|
|
14
14
|
"requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
|
|
15
15
|
"api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
|
|
16
|
+
"architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk] },
|
|
16
17
|
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
17
18
|
"change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
|
|
18
19
|
"execute" => { "checkers" => %w[change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
|
|
@@ -38,6 +38,7 @@ def configured_checkers(workflow, step_id)
|
|
|
38
38
|
case step_id
|
|
39
39
|
when "requirement-baseline" then %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check]
|
|
40
40
|
when "api-contract" then %w[requirement-graph-check contract-coverage-check change-impact-check]
|
|
41
|
+
when "architecture-feasibility" then %w[requirement-graph-check change-impact-check]
|
|
41
42
|
when "integration-verify", "change-plan" then %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check]
|
|
42
43
|
when "verify" then %w[acceptance-coverage-check change-impact-check]
|
|
43
44
|
else []
|