@guanlin4924/devflow-pi 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +425 -54
  3. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +3 -2
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_assessment.rb +458 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +256 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +155 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +176 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +46 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +94 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +2 -1
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +646 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/runtime_context_collector.rb +210 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/stale_check.rb +56 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/static_runtime_context_collector.rb +518 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +140 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +88 -2
  19. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -2
  20. package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -1
  21. package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +37 -0
  22. package/plugins/devflow-design/skills/workflow-architecture-feasibility/agents/openai.yaml +4 -0
  23. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +7 -2
  24. package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -1
  25. package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +4 -0
  26. package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +4 -0
  27. package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +4 -0
  28. package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +4 -0
  29. package/src/cli.ts +2 -1
  30. package/src/runtime.ts +2 -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