@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
@@ -117,6 +117,7 @@ After each step completes, verify the step's gate before advancing:
117
117
  | requirement-feasibility-review | 可实现性审查.meta.json `status=ready_for_baseline`、`baseline_gate=pass`、`gap_count=0` 且无 critical finding | Reject advance to requirement-baseline |
118
118
  | requirement-baseline | baseline.md review conclusion is "通过" AND baseline.meta.json `review_status` / `traceability_status` are `passed` AND blocking counters are all 0 | Reject advance to api-contract |
119
119
  | api-contract | api-contract.meta.json `review_status` / `traceability_status` are `passed` AND `pending_contract_count` is 0 | Reject advance to dual tracks |
120
+ | architecture-feasibility | Core-derived `architecture-brief.md` / meta exist. If `assessment_level=full`, require `assessment_status=ready` and `comparison_status=confirmed`; a lightweight incomplete result may proceed only as an explicitly unresolved downstream input. | Reject advance to dual tracks for an incomplete full assessment |
120
121
  | ui-spec | UI-SPEC.md review conclusion is "通过" | Reject advance to feature-breakdown |
121
122
  | feature-breakdown | 功能点梳理.md review conclusion is "通过" | Reject advance to tech-specs |
122
123
  | api-call-guide | api-call-guide.meta.json `frontend_consumable` and `mock_ready` are true, pending_items is empty | Reject merge gate |
@@ -161,8 +162,8 @@ When the inspector returns `route_failure_branch`:
161
162
  When the inspector returns `run_parallel_window`:
162
163
 
163
164
  0. **Track filtering**(新增):读取 STATE.md 的 `active_track` 字段
164
- - 如果 `active_track == "A"`:只路由完整需求主链及 Track A 步骤(requirement-triage、requirement-wayfinder、domain-modeling、requirement-feasibility-review、requirement-baseline、api-contract、backend-tech-spec、api-call-guide),完成后阻断在轨道边界
165
- - 如果 `active_track == "B"`:新需求必须完成完整需求主链;前端设计步骤仍依赖 api-contract。允许步骤为 requirement-triage、requirement-wayfinder、domain-modeling、requirement-feasibility-review、requirement-baseline、api-contract、ui-spec、feature-breakdown、frontend-tech-spec、api-supplement,完成后阻断在轨道边界
165
+ - 如果 `active_track == "A"`:只路由完整需求主链及 Track A 步骤(requirement-triage、requirement-wayfinder、domain-modeling、requirement-feasibility-review、requirement-baseline、api-contract、architecture-feasibility、backend-tech-spec、api-call-guide),完成后阻断在轨道边界
166
+ - 如果 `active_track == "B"`:新需求必须完成完整需求主链;前端设计步骤仍依赖 api-contract 和 architecture-feasibility。允许步骤为 requirement-triage、requirement-wayfinder、domain-modeling、requirement-feasibility-review、requirement-baseline、api-contract、architecture-feasibility、ui-spec、feature-breakdown、frontend-tech-spec、api-supplement,完成后阻断在轨道边界
166
167
  - 如果未设置或无效:保留全部 branch_steps(双轨道)
167
168
  - 过滤后如果只剩一个步骤,退化为串行执行(不启动并行窗口)
168
169
 
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Architecture Assessment P0: the assessment never accepts a caller-supplied
5
+ # manifest or delta set. A user-confirmed, Core-written immutable manifest is
6
+ # the only input besides the pinned runtime-context snapshot.
7
+ require "digest"
8
+ require "json"
9
+ require "open3"
10
+ require "optparse"
11
+ require "pathname"
12
+ require "set"
13
+ require "time"
14
+ require_relative "control_io"
15
+ require_relative "runtime_context_collector"
16
+
17
+ module ArchitectureAssessment
18
+ SCHEMA_VERSION = 1
19
+ RULE_VERSION = 1
20
+ ROOT_DIR = File.join(".planning", "architecture-assessment")
21
+
22
+ UNRESOLVED = {
23
+ DELTA_SET_BINDING_FAILED: "DELTA_SET_BINDING_FAILED",
24
+ BASELINE_COVERAGE_INCOMPLETE: "BASELINE_COVERAGE_INCOMPLETE",
25
+ DELTA_SOURCE_UNVERIFIABLE: "DELTA_SOURCE_UNVERIFIABLE"
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ def safe_component!(value, label)
31
+ text = value.to_s.strip
32
+ unless text.match?(/\A[\p{L}\p{N}][\p{L}\p{N}._-]{0,119}\z/u)
33
+ raise ArgumentError, "#{label} must be a 1-120 character path-safe identifier"
34
+ end
35
+ text
36
+ end
37
+
38
+ # RFC 8785-style canonical JSON for the bounded P0 data model. Every key is
39
+ # a String before hashing, so Ruby Hash insertion order cannot change a hash.
40
+ def canonical_json(value)
41
+ case value
42
+ when Hash
43
+ "{" + value.keys.map(&:to_s).sort.map { |key| JSON.generate(key) + ":" + canonical_json(value[key]) }.join(",") + "}"
44
+ when Array
45
+ "[" + value.map { |item| canonical_json(item) }.join(",") + "]"
46
+ when String
47
+ JSON.generate(value)
48
+ when Numeric
49
+ value.to_s
50
+ when true, false
51
+ value.to_s
52
+ when nil
53
+ "null"
54
+ else
55
+ JSON.generate(value)
56
+ end
57
+ end
58
+
59
+ def sha256(value)
60
+ Digest::SHA256.hexdigest(value)
61
+ end
62
+
63
+ def normalize_subject(value)
64
+ value.to_s.strip.downcase
65
+ end
66
+
67
+ def canonical_source(source)
68
+ source = source.is_a?(Hash) ? source : {}
69
+ {
70
+ "kind" => source["kind"].to_s,
71
+ "locator" => source["locator"].to_s,
72
+ "input_hash" => source["input_hash"].to_s,
73
+ "repository_revision" => source["repository_revision"].to_s
74
+ }
75
+ end
76
+
77
+ # Only this projection is confirmed by the user and persisted in a manifest.
78
+ # Model-only presentation fields such as proposer/status are deliberately
79
+ # omitted from the decision input.
80
+ def canonical_delta(delta)
81
+ delta = delta.is_a?(Hash) ? delta : {}
82
+ {
83
+ "op" => delta["op"].to_s,
84
+ "subject" => normalize_subject(delta["subject"]),
85
+ "source" => canonical_source(delta["source"])
86
+ }
87
+ end
88
+
89
+ def canonical_delta_hash(delta)
90
+ sha256(canonical_json(canonical_delta(delta)))
91
+ end
92
+
93
+ def stable_sorted_union(lists)
94
+ lists.flatten.compact.uniq.sort
95
+ end
96
+
97
+ def manifest_path(root, feature, delta_set_id)
98
+ feature = safe_component!(feature, "feature")
99
+ delta_set_id = safe_component!(delta_set_id, "deltaSetId")
100
+ File.join(root, ROOT_DIR, feature, "delta-set-manifests", "#{delta_set_id}.json")
101
+ end
102
+
103
+ def assessment_dir(root, feature)
104
+ File.join(root, ROOT_DIR, safe_component!(feature, "feature"))
105
+ end
106
+
107
+ def assessment_pointer_path(root, feature)
108
+ File.join(assessment_dir(root, feature), "current.json")
109
+ end
110
+
111
+ def assessment_history_path(root, feature, revision)
112
+ File.join(assessment_dir(root, feature), "revisions", "#{revision}.json")
113
+ end
114
+
115
+ def assessment_audit_path(root, feature)
116
+ File.join(assessment_dir(root, feature), "audit.jsonl")
117
+ end
118
+
119
+ def current_assessment_revision(root, feature)
120
+ pointer = assessment_pointer_path(root, feature)
121
+ return 0 unless File.exist?(pointer)
122
+
123
+ JSON.parse(File.read(pointer))["current_revision"].to_i
124
+ rescue JSON::ParserError
125
+ raise ArgumentError, "assessment pointer is invalid JSON: #{pointer}"
126
+ end
127
+
128
+ def parse_locator(locator)
129
+ path, anchor = locator.to_s.split("#", 2)
130
+ path = path.to_s.strip
131
+ anchor = anchor.to_s.strip
132
+ raise ArgumentError, "source locator must be project-relative path#heading" if path.empty? || anchor.empty?
133
+ raise ArgumentError, "source locator cannot be absolute" if Pathname.new(path).absolute?
134
+ normalized = Pathname.new(path).cleanpath.to_s.tr("\\", "/")
135
+ raise ArgumentError, "source locator cannot traverse parent directories" if normalized == ".." || normalized.start_with?("../")
136
+ [normalized, anchor]
137
+ end
138
+
139
+ def git_capture(root, *args)
140
+ stdout, stderr, status = Open3.capture3("git", "-C", root, *args)
141
+ [stdout, stderr, status.success?]
142
+ end
143
+
144
+ def markdown_section(content, anchor)
145
+ lines = content.lines
146
+ target = anchor.to_s.strip
147
+ start = nil
148
+ level = nil
149
+ lines.each_with_index do |line, index|
150
+ match = line.match(/\A(#+)\s+(.+?)\s*\z/)
151
+ next unless match
152
+
153
+ title = match[2].sub(/\s+#+\s*\z/, "").strip
154
+ next unless title == target
155
+
156
+ start = index
157
+ level = match[1].length
158
+ break
159
+ end
160
+ return nil unless start
161
+
162
+ finish = lines.length
163
+ ((start + 1)...lines.length).each do |index|
164
+ match = lines[index].match(/\A(#+)\s+/)
165
+ next unless match && match[1].length <= level
166
+
167
+ finish = index
168
+ break
169
+ end
170
+ lines[start...finish].join
171
+ end
172
+
173
+ # Proves the source claim by loading the exact Git object, resolving the
174
+ # declared Markdown section, and comparing its bytes to input_hash.
175
+ def source_valid?(root, source)
176
+ source = canonical_source(source)
177
+ return false if source.values.any?(&:empty?)
178
+
179
+ path, anchor = parse_locator(source["locator"])
180
+ _stdout, _stderr, commit_exists = git_capture(root, "cat-file", "-e", "#{source["repository_revision"]}^{commit}")
181
+ return false unless commit_exists
182
+
183
+ content, _show_error, shown = git_capture(root, "show", "#{source["repository_revision"]}:#{path}")
184
+ return false unless shown
185
+
186
+ section = markdown_section(content, anchor)
187
+ !section.nil? && sha256(section) == source["input_hash"]
188
+ rescue ArgumentError
189
+ false
190
+ end
191
+
192
+ def validate_delta(delta, expected_source_revision: nil)
193
+ errors = []
194
+ errors << "delta must be an object" unless delta.is_a?(Hash)
195
+ return errors unless delta.is_a?(Hash)
196
+
197
+ safe_component!(delta["id"], "delta id")
198
+ errors << "delta #{delta["id"]}: only require_runtime_dep is supported in P0" unless delta["op"] == "require_runtime_dep"
199
+ errors << "delta #{delta["id"]}: subject is required" if normalize_subject(delta["subject"]).empty?
200
+ source = canonical_source(delta["source"])
201
+ errors << "delta #{delta["id"]}: source kind is required" if source["kind"].empty?
202
+ errors << "delta #{delta["id"]}: source locator is required" if source["locator"].empty?
203
+ errors << "delta #{delta["id"]}: source input_hash is required" if source["input_hash"].empty?
204
+ errors << "delta #{delta["id"]}: source repository_revision is required" if source["repository_revision"].empty?
205
+ if expected_source_revision && source["repository_revision"] != expected_source_revision
206
+ errors << "delta #{delta["id"]}: source repository_revision does not match source_bundle_revision"
207
+ end
208
+ errors
209
+ rescue ArgumentError => error
210
+ errors << error.message
211
+ errors
212
+ end
213
+
214
+ def read_manifest(root, feature, delta_set_id)
215
+ path = manifest_path(root, feature, delta_set_id)
216
+ raise ArgumentError, "confirmed delta-set manifest not found: #{path}" unless File.exist?(path)
217
+
218
+ [JSON.parse(File.read(path)), path]
219
+ rescue JSON::ParserError
220
+ raise ArgumentError, "confirmed delta-set manifest is invalid JSON: #{path}"
221
+ end
222
+
223
+ def manifest_errors(manifest, feature, delta_set_id)
224
+ errors = []
225
+ attestation = manifest["attestation"].is_a?(Hash) ? manifest["attestation"] : {}
226
+ errors << "manifest feature mismatch" unless manifest["feature"] == feature
227
+ errors << "manifest delta_set_id mismatch" unless manifest["delta_set_id"] == delta_set_id
228
+ errors << "manifest attestation.verified_by must be core" unless attestation["verified_by"] == "core"
229
+ errors << "manifest attestation.method must be ui_confirm" unless attestation["method"] == "ui_confirm"
230
+ errors << "manifest attestation.principal must be interactive-user" unless attestation["principal"] == "interactive-user"
231
+ errors << "manifest attestation.confirmed_at is missing" if attestation["confirmed_at"].to_s.empty?
232
+ source_bundle_revision = manifest["source_bundle_revision"].to_s
233
+ errors << "manifest source_bundle_revision is missing" if source_bundle_revision.empty?
234
+ items = Array(manifest["items"])
235
+ ids = items.map { |item| item.is_a?(Hash) ? item["id"] : nil }
236
+ errors << "manifest item ids are duplicated" unless ids.compact.uniq.length == ids.length && ids.none?(&:nil?)
237
+ items.each do |item|
238
+ unless item.is_a?(Hash)
239
+ errors << "manifest item must be an object"
240
+ next
241
+ end
242
+ delta = item["canonical_delta"]
243
+ errors.concat(validate_delta(delta, expected_source_revision: source_bundle_revision))
244
+ errors << "#{item["id"]}: id does not match canonical delta" unless delta.is_a?(Hash) && item["id"] == delta["id"]
245
+ expected_hash = delta.is_a?(Hash) ? canonical_delta_hash(delta) : nil
246
+ errors << "#{item["id"]}: canonical_delta_hash mismatch" unless item["canonical_delta_hash"] == expected_hash
247
+ end
248
+ errors.uniq
249
+ end
250
+
251
+ def in_inventory?(snapshot, subject)
252
+ normalized = normalize_subject(subject)
253
+ Array(snapshot["inventory"]).any? { |item| normalize_subject(item["subject"]) == normalized }
254
+ end
255
+
256
+ def classify(delta, snapshot, root)
257
+ coverage_complete = snapshot["coverage_status"] == "complete"
258
+ source_is_valid = source_valid?(root, delta["source"])
259
+ return { "predicate" => nil, "confidence" => "incomplete", "source_valid" => source_is_valid } unless coverage_complete && source_is_valid
260
+
261
+ subject = normalize_subject(delta["subject"])
262
+ predicate = in_inventory?(snapshot, subject) ? "dependency_satisfied" : "new_declared_dependency"
263
+ {
264
+ "predicate" => predicate,
265
+ "confidence" => "confirmed",
266
+ "subject" => subject,
267
+ "source_valid" => true,
268
+ "inputs" => ["snapshot:inventory", "snapshot:coverage", "delta:#{delta["id"]}"]
269
+ }
270
+ end
271
+
272
+ def assess(root, feature, snapshot, manifest, delta_set_id)
273
+ binding = manifest_errors(manifest, feature, delta_set_id)
274
+ unless binding.empty?
275
+ return {
276
+ "feature" => feature, "outcome" => "lightweight",
277
+ "assessment_completeness" => "incomplete",
278
+ "would_require" => ["re_attest_delta_set"], "would_block" => [],
279
+ "unresolved_questions" => [UNRESOLVED[:DELTA_SET_BINDING_FAILED]],
280
+ "binding_errors" => binding, "findings" => []
281
+ }
282
+ end
283
+
284
+ deltas = Array(manifest["items"]).map { |item| item["canonical_delta"] }
285
+ findings = deltas.map { |delta| classify(delta, snapshot, root).merge("delta_id" => delta["id"]) }
286
+ incomplete = snapshot["coverage_status"] != "complete" || findings.any? { |finding| finding["confidence"] == "incomplete" }
287
+ if incomplete
288
+ codes = []
289
+ codes << UNRESOLVED[:BASELINE_COVERAGE_INCOMPLETE] if snapshot["coverage_status"] != "complete"
290
+ codes << UNRESOLVED[:DELTA_SOURCE_UNVERIFIABLE] if findings.any? { |finding| finding["confidence"] == "incomplete" }
291
+ return {
292
+ "feature" => feature, "outcome" => "lightweight",
293
+ "assessment_completeness" => "incomplete",
294
+ "would_require" => ["clarify_or_rescan"], "would_block" => [],
295
+ "unresolved_questions" => stable_sorted_union([codes]), "findings" => findings
296
+ }
297
+ end
298
+
299
+ new_dependencies = findings.select { |finding| finding["predicate"] == "new_declared_dependency" }
300
+ if new_dependencies.empty?
301
+ {
302
+ "feature" => feature, "outcome" => "none",
303
+ "assessment_completeness" => "complete",
304
+ "would_require" => [], "would_block" => [], "unresolved_questions" => [], "findings" => findings
305
+ }
306
+ else
307
+ {
308
+ "feature" => feature, "outcome" => "full",
309
+ "assessment_completeness" => "complete",
310
+ "would_require" => ["decision"], "would_block" => [], "unresolved_questions" => [], "findings" => findings
311
+ }
312
+ end
313
+ end
314
+
315
+ def write_assessment(root, feature, expected_revision, assessment, manifest_path_value, base_snapshot_revision, delta_set_id)
316
+ current = current_assessment_revision(root, feature)
317
+ raise ArgumentError, "assessment revision conflict: expected #{expected_revision}, current #{current}" unless expected_revision == current
318
+
319
+ revision = current + 1
320
+ final = assessment.merge(
321
+ "schema_version" => SCHEMA_VERSION,
322
+ "rule_version" => RULE_VERSION,
323
+ "assessment_revision" => revision,
324
+ "base_snapshot_revision" => base_snapshot_revision,
325
+ "delta_set_id" => delta_set_id,
326
+ "manifest_path" => manifest_path_value.sub("#{root}/", ""),
327
+ "manifest_hash" => sha256(File.read(manifest_path_value))
328
+ )
329
+ history = assessment_history_path(root, feature, revision)
330
+ DevflowControlIO.atomic_create(history, JSON.pretty_generate(final) + "\n")
331
+ DevflowControlIO.atomic_write(assessment_pointer_path(root, feature), JSON.pretty_generate({ "current_revision" => revision, "path" => history.sub("#{root}/", "") }) + "\n")
332
+ DevflowControlIO.append_audit(assessment_audit_path(root, feature), {
333
+ "event" => "assessment_written", "assessment_revision" => revision,
334
+ "base_snapshot_revision" => base_snapshot_revision, "delta_set_id" => delta_set_id,
335
+ "manifest_hash" => final["manifest_hash"], "outcome" => final["outcome"], "at" => Time.now.utc.iso8601
336
+ })
337
+ DevflowControlIO.register_control_artifact(root, {
338
+ "path" => history.sub("#{root}/", ""), "type" => "architecture_assessment",
339
+ "produced_by" => "core", "assessment_revision" => revision,
340
+ "outcome" => final["outcome"], "base_snapshot_revision" => base_snapshot_revision,
341
+ "delta_set_id" => delta_set_id
342
+ })
343
+ final.merge("path" => history.sub("#{root}/", ""))
344
+ end
345
+
346
+ def write_manifest(root, feature, delta_set_id, source_bundle_revision, deltas)
347
+ raise ArgumentError, "source_bundle_revision is required" if source_bundle_revision.to_s.empty?
348
+ raise ArgumentError, "deltas must be a non-empty array" unless deltas.is_a?(Array) && !deltas.empty?
349
+ errors = deltas.flat_map { |delta| validate_delta(delta, expected_source_revision: source_bundle_revision) }
350
+ ids = deltas.map { |delta| delta.is_a?(Hash) ? delta["id"] : nil }
351
+ errors << "duplicate delta ids" unless ids.compact.uniq.length == ids.length && ids.none?(&:nil?)
352
+ raise ArgumentError, errors.uniq.join("; ") unless errors.empty?
353
+
354
+ manifest = {
355
+ "schema_version" => SCHEMA_VERSION,
356
+ "delta_set_id" => delta_set_id,
357
+ "feature" => feature,
358
+ "source_bundle_revision" => source_bundle_revision,
359
+ "items" => deltas.map do |delta|
360
+ canonical = canonical_delta(delta).merge("id" => delta["id"])
361
+ { "id" => delta["id"], "canonical_delta" => canonical, "canonical_delta_hash" => canonical_delta_hash(canonical) }
362
+ end,
363
+ "attestation" => {
364
+ "principal" => "interactive-user", "verified_by" => "core", "method" => "ui_confirm", "confirmed_at" => Time.now.utc.iso8601
365
+ }
366
+ }
367
+ destination = manifest_path(root, feature, delta_set_id)
368
+ DevflowControlIO.atomic_create(destination, JSON.pretty_generate(manifest) + "\n")
369
+ DevflowControlIO.append_audit(assessment_audit_path(root, feature), {
370
+ "event" => "manifest_confirmed", "delta_set_id" => delta_set_id,
371
+ "manifest_hash" => sha256(File.read(destination)), "at" => Time.now.utc.iso8601
372
+ })
373
+ DevflowControlIO.register_control_artifact(root, {
374
+ "path" => destination.sub("#{root}/", ""), "type" => "delta_set_manifest",
375
+ "produced_by" => "core", "delta_set_id" => delta_set_id
376
+ })
377
+ [manifest, destination]
378
+ end
379
+ end
380
+
381
+ if __FILE__ == $PROGRAM_NAME
382
+ options = {
383
+ feature: nil, base_snapshot_revision: nil, delta_set_id: nil, delta_json: nil,
384
+ source_bundle_revision: nil, expected_assessment_revision: nil,
385
+ confirmed_interactively: false, write: false, json: false
386
+ }
387
+ command = ARGV.shift
388
+ OptionParser.new do |o|
389
+ o.on("--feature NAME") { |value| options[:feature] = value }
390
+ o.on("--base-snapshot-revision N", Integer) { |value| options[:base_snapshot_revision] = value }
391
+ o.on("--delta-set-id ID") { |value| options[:delta_set_id] = value }
392
+ o.on("--delta-json JSON") { |value| options[:delta_json] = value }
393
+ o.on("--source-bundle-revision REV") { |value| options[:source_bundle_revision] = value }
394
+ o.on("--expected-assessment-revision N", Integer) { |value| options[:expected_assessment_revision] = value }
395
+ o.on("--confirmed-interactively") { options[:confirmed_interactively] = true }
396
+ o.on("--write") { options[:write] = true }
397
+ o.on("--json") { options[:json] = true }
398
+ end.parse!
399
+
400
+ root = Dir.pwd
401
+ writes = command == "write-manifest" || (command == "assess" && options[:write])
402
+ lock = writes ? DevflowControlIO.acquire_project_lock(root) : nil
403
+ begin
404
+ result =
405
+ case command
406
+ when "inspect"
407
+ raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
408
+ feature = ArchitectureAssessment.safe_component!(options[:feature], "feature")
409
+ result = {
410
+ "ok" => true,
411
+ "current_assessment_revision" => ArchitectureAssessment.current_assessment_revision(root, feature)
412
+ }
413
+ unless options[:delta_set_id].to_s.empty?
414
+ delta_set_id = ArchitectureAssessment.safe_component!(options[:delta_set_id], "deltaSetId")
415
+ manifest = ArchitectureAssessment.manifest_path(root, feature, delta_set_id)
416
+ result.merge!("manifest_exists" => File.exist?(manifest), "manifest_path" => manifest.sub("#{root}/", ""))
417
+ end
418
+ result
419
+ when "write-manifest"
420
+ raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
421
+ raise ArgumentError, "--delta-set-id is required" if options[:delta_set_id].to_s.empty?
422
+ raise ArgumentError, "--source-bundle-revision is required" if options[:source_bundle_revision].to_s.empty?
423
+ raise ArgumentError, "--delta-json is required" if options[:delta_json].to_s.empty?
424
+ raise ArgumentError, "--confirmed-interactively is required" unless options[:confirmed_interactively]
425
+ raise ArgumentError, "--write is required" unless options[:write]
426
+ feature = ArchitectureAssessment.safe_component!(options[:feature], "feature")
427
+ delta_set_id = ArchitectureAssessment.safe_component!(options[:delta_set_id], "deltaSetId")
428
+ deltas = JSON.parse(options[:delta_json])
429
+ manifest, path = ArchitectureAssessment.write_manifest(root, feature, delta_set_id, options[:source_bundle_revision], deltas)
430
+ { "ok" => true, "delta_set_id" => delta_set_id, "manifest_hash" => ArchitectureAssessment.sha256(File.read(path)), "path" => path.sub("#{root}/", ""), "manifest" => manifest }
431
+ when "assess"
432
+ raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
433
+ raise ArgumentError, "--base-snapshot-revision is required" if options[:base_snapshot_revision].nil?
434
+ raise ArgumentError, "--delta-set-id is required" if options[:delta_set_id].to_s.empty?
435
+ feature = ArchitectureAssessment.safe_component!(options[:feature], "feature")
436
+ delta_set_id = ArchitectureAssessment.safe_component!(options[:delta_set_id], "deltaSetId")
437
+ snapshot_path = File.join(root, RuntimeContextCollector::HISTORY_DIR, "#{options[:base_snapshot_revision]}.json")
438
+ raise ArgumentError, "base snapshot not found: #{snapshot_path}" unless File.exist?(snapshot_path)
439
+ snapshot = JSON.parse(File.read(snapshot_path))
440
+ manifest, path = ArchitectureAssessment.read_manifest(root, feature, delta_set_id)
441
+ assessment = ArchitectureAssessment.assess(root, feature, snapshot, manifest, delta_set_id).merge(
442
+ "base_snapshot_revision" => options[:base_snapshot_revision], "delta_set_id" => delta_set_id
443
+ )
444
+ if options[:write]
445
+ raise ArgumentError, "--expected-assessment-revision is required when writing" if options[:expected_assessment_revision].nil?
446
+ assessment = ArchitectureAssessment.write_assessment(root, feature, options[:expected_assessment_revision], assessment, path, options[:base_snapshot_revision], delta_set_id)
447
+ end
448
+ { "ok" => true }.merge(assessment)
449
+ else
450
+ raise ArgumentError, "command must be inspect, write-manifest, or assess"
451
+ end
452
+ rescue StandardError => error
453
+ result = { "ok" => false, "reason" => "architecture_assessment_failed", "message" => error.message }
454
+ ensure
455
+ DevflowControlIO.release_lock(lock)
456
+ end
457
+ puts JSON.pretty_generate(result)
458
+ end