@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.
Files changed (24) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +297 -14
  4. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +3 -2
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_assessment.rb +458 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +256 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +155 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +176 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +46 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +187 -12
  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 +83 -1
  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 +54 -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
@@ -0,0 +1,518 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Static Runtime Context P1. Unlike runtime_context_collector.rb (P0), this
5
+ # produces a separate v2 evidence projection. It must never turn a repository
6
+ # declaration or code reference into a statement about a running environment.
7
+ require "digest"
8
+ require "json"
9
+ require "open3"
10
+ require "optparse"
11
+ require "rexml/document"
12
+ require "rexml/xpath"
13
+ require "time"
14
+ require "yaml"
15
+ require_relative "control_io"
16
+
17
+ module StaticRuntimeContextCollector
18
+ SCHEMA_VERSION = 2
19
+ COLLECTOR_VERSION = "static-runtime-context-v2.1"
20
+ ROOT_DIR = File.join(".context", "static-runtime-context")
21
+ HISTORY_DIR = File.join(ROOT_DIR, "revisions")
22
+ POINTER = ".context/static-runtime-context.json"
23
+ AUDIT_PATH = File.join(ROOT_DIR, "audit.jsonl")
24
+ MAX_COMPOSE_BYTES = 1_048_576
25
+ EXCLUDED_ROOTS = %w[node_modules vendor dist build .git].freeze
26
+ MANIFEST_EXTRACTORS = {
27
+ "package.json" => :extract_package_json,
28
+ "composer.json" => :extract_composer_json,
29
+ "requirements.txt" => :extract_requirements_txt,
30
+ "pom.xml" => :extract_pom_xml,
31
+ "go.mod" => :extract_go_mod
32
+ }.freeze
33
+
34
+ module_function
35
+
36
+ def canonical_json(value)
37
+ case value
38
+ when Hash
39
+ "{" + value.keys.map(&:to_s).sort.map { |key| JSON.generate(key) + ":" + canonical_json(value[key]) }.join(",") + "}"
40
+ when Array
41
+ "[" + value.map { |item| canonical_json(item) }.join(",") + "]"
42
+ when String
43
+ JSON.generate(value)
44
+ when Numeric
45
+ value.to_s
46
+ when true, false
47
+ value.to_s
48
+ when nil
49
+ "null"
50
+ else
51
+ JSON.generate(value)
52
+ end
53
+ end
54
+
55
+ def sha256(value)
56
+ Digest::SHA256.hexdigest(value)
57
+ end
58
+
59
+ def normalize_subject(value)
60
+ value.to_s.strip.downcase
61
+ end
62
+
63
+ def safe_version(value)
64
+ text = value.to_s.strip
65
+ return nil if text.empty? || text.match?(%r{[:/@]}) || text.match?(/(?:token|secret|password)=/i)
66
+
67
+ text
68
+ end
69
+
70
+ def extract_package_json(content)
71
+ parsed = JSON.parse(content)
72
+ dependencies = parsed["dependencies"].is_a?(Hash) ? parsed["dependencies"] : {}
73
+ dependencies.map { |name, version| { "subject" => normalize_subject(name), "version" => safe_version(version) } }
74
+ rescue JSON::ParserError
75
+ raise "parse_error"
76
+ end
77
+
78
+ def extract_composer_json(content)
79
+ parsed = JSON.parse(content)
80
+ dependencies = parsed["require"].is_a?(Hash) ? parsed["require"] : {}
81
+ dependencies.map { |name, version| { "subject" => normalize_subject(name), "version" => safe_version(version) } }
82
+ rescue JSON::ParserError
83
+ raise "parse_error"
84
+ end
85
+
86
+ def extract_requirements_txt(content)
87
+ content.lines.map(&:strip).reject { |line| line.empty? || line.start_with?("#", "-e", "--", "-r", "-f") }.map do |line|
88
+ name = line.split(/[<>=!\[\s]/, 2).first.to_s
89
+ version = line.match(/\A[^<>=!\[\s]+\s*(?:==|>=|<=|~=|>|<)\s*([^\s;]+)\z/)&.[](1)
90
+ { "subject" => normalize_subject(name), "version" => safe_version(version) }
91
+ end
92
+ end
93
+
94
+ def extract_pom_xml(content)
95
+ document = REXML::Document.new(content)
96
+ REXML::XPath.match(document, "//dependency").map do |dependency|
97
+ group = dependency.elements["groupId"]&.text.to_s.strip
98
+ artifact = dependency.elements["artifactId"]&.text.to_s.strip
99
+ next if group.empty? || artifact.empty?
100
+
101
+ { "subject" => normalize_subject("#{group}:#{artifact}"), "version" => safe_version(dependency.elements["version"]&.text) }
102
+ end.compact
103
+ rescue REXML::ParseException
104
+ raise "parse_error"
105
+ end
106
+
107
+ # The grammar used here is deliberately small: direct and parenthesized
108
+ # require directives. Unsupported Go syntax becomes a parse failure instead
109
+ # of an incomplete-looking positive inventory.
110
+ def extract_go_mod(content)
111
+ entries = []
112
+ in_block = false
113
+ content.each_line do |line|
114
+ stripped = line.strip
115
+ next if stripped.empty? || stripped.start_with?("//")
116
+
117
+ if stripped == "require ("
118
+ in_block = true
119
+ next
120
+ end
121
+ if in_block && stripped == ")"
122
+ in_block = false
123
+ next
124
+ end
125
+ candidate = in_block ? stripped : stripped.sub(/\Arequire\s+/, "")
126
+ next if !in_block && candidate == stripped
127
+
128
+ match = candidate.match(/\A([^\s]+)\s+([^\s]+)(?:\s+\/\/.*)?\z/)
129
+ raise "parse_error" unless match
130
+
131
+ entries << { "subject" => normalize_subject(match[1]), "version" => safe_version(match[2]) }
132
+ end
133
+ raise "parse_error" if in_block
134
+
135
+ entries
136
+ end
137
+
138
+ def git_capture(root, *args)
139
+ stdout, stderr, status = Open3.capture3("git", "-C", root, *args)
140
+ [stdout, stderr, status.success?]
141
+ end
142
+
143
+ def git_revision(root)
144
+ output, _error, success = git_capture(root, "rev-parse", "HEAD")
145
+ success ? output.strip : ""
146
+ end
147
+
148
+ def git_paths(root, revision)
149
+ output, error, success = git_capture(root, "ls-tree", "-r", "--name-only", revision)
150
+ raise "git_tree_unavailable: #{error.strip}" unless success
151
+
152
+ output.lines.map(&:strip).reject(&:empty?).sort
153
+ end
154
+
155
+ def git_content(root, revision, path)
156
+ output, error, success = git_capture(root, "show", "#{revision}:#{path}")
157
+ raise "git_show_failed: #{error.strip}" unless success
158
+
159
+ output
160
+ end
161
+
162
+ def excluded_path?(path)
163
+ path.split("/").any? { |segment| EXCLUDED_ROOTS.include?(segment) }
164
+ end
165
+
166
+ def dependency_discovery(root, revision)
167
+ all_paths = git_paths(root, revision)
168
+ matching = all_paths.select { |path| MANIFEST_EXTRACTORS.key?(File.basename(path)) }
169
+ included, excluded = matching.partition { |path| !excluded_path?(path) }
170
+ files = excluded.map { |path| { "path" => path, "status" => "excluded", "reason" => "third_party_or_build_output" } }
171
+ entries = []
172
+ included.each do |path|
173
+ extractor = MANIFEST_EXTRACTORS.fetch(File.basename(path))
174
+ begin
175
+ content = git_content(root, revision, path)
176
+ extract = send(extractor, content)
177
+ digest = sha256(content)
178
+ extract.each do |entry|
179
+ subject = normalize_subject(entry["subject"])
180
+ next if subject.empty?
181
+
182
+ entries << {
183
+ "subject" => subject,
184
+ "version" => entry["version"],
185
+ "evidence" => {
186
+ "source_type" => "manifest",
187
+ "path" => path,
188
+ "git_revision" => revision,
189
+ "sha256" => digest,
190
+ "extractor" => extractor.to_s
191
+ }
192
+ }
193
+ end
194
+ files << { "path" => path, "status" => "scanned", "extractor" => extractor.to_s, "sha256" => digest }
195
+ rescue StandardError => error
196
+ files << { "path" => path, "status" => "failed", "extractor" => extractor.to_s, "error" => error.message }
197
+ end
198
+ end
199
+ [files.sort_by { |file| file["path"] }, entries]
200
+ end
201
+
202
+ # A mini-program claim is emitted only when app.json is valid JSON and has
203
+ # the defining pages array. We deliberately do not infer web by default.
204
+ def platform_discovery(root, revision)
205
+ candidates = git_paths(root, revision).select { |path| File.basename(path) == "app.json" }
206
+ files = []
207
+ entries = []
208
+ candidates.each do |path|
209
+ if excluded_path?(path)
210
+ files << { "path" => path, "status" => "excluded", "reason" => "third_party_or_build_output" }
211
+ next
212
+ end
213
+ begin
214
+ content = git_content(root, revision, path)
215
+ parsed = JSON.parse(content)
216
+ digest = sha256(content)
217
+ if parsed["pages"].is_a?(Array)
218
+ entries << {
219
+ "subject" => "miniprogram",
220
+ "evidence" => {
221
+ "source_type" => "manifest",
222
+ "path" => path,
223
+ "git_revision" => revision,
224
+ "sha256" => digest,
225
+ "extractor" => "miniprogram_app_json_v1"
226
+ }
227
+ }
228
+ files << { "path" => path, "status" => "scanned", "extractor" => "miniprogram_app_json_v1", "sha256" => digest }
229
+ else
230
+ files << { "path" => path, "status" => "ignored", "reason" => "not_a_miniprogram_manifest", "sha256" => digest }
231
+ end
232
+ rescue JSON::ParserError
233
+ files << { "path" => path, "status" => "failed", "extractor" => "miniprogram_app_json_v1", "error" => "parse_error" }
234
+ rescue StandardError => error
235
+ files << { "path" => path, "status" => "failed", "extractor" => "miniprogram_app_json_v1", "error" => error.message }
236
+ end
237
+ end
238
+ [files.sort_by { |file| file["path"] }, entries]
239
+ end
240
+
241
+ def compose_path?(path)
242
+ File.basename(path).match?(%r{\A(?:docker-)?compose(?:[._-][A-Za-z0-9._-]+)?\.ya?ml\z}i)
243
+ end
244
+
245
+ # Docker Compose is intentionally the first and only configuration adapter.
246
+ # It proves only that a committed descriptor declares a service name; image,
247
+ # environment, endpoint, and deployment state are never copied or inferred.
248
+ def compose_discovery(root, revision)
249
+ candidates = git_paths(root, revision).select { |path| compose_path?(path) }
250
+ files = []
251
+ entries = []
252
+ candidates.each do |path|
253
+ if excluded_path?(path)
254
+ files << { "path" => path, "status" => "excluded", "reason" => "third_party_or_build_output" }
255
+ next
256
+ end
257
+ begin
258
+ content = git_content(root, revision, path)
259
+ raise "file_too_large" if content.bytesize > MAX_COMPOSE_BYTES
260
+ # Compose files commonly use x-* anchors. Allow aliases only after a
261
+ # bounded read and with Psych safe loading; no values are projected.
262
+ parsed = YAML.safe_load(content, permitted_classes: [], aliases: true)
263
+ digest = sha256(content)
264
+ services = parsed.is_a?(Hash) ? parsed["services"] : nil
265
+ unless services.is_a?(Hash)
266
+ files << { "path" => path, "status" => "ignored", "reason" => "not_a_compose_services_descriptor", "sha256" => digest }
267
+ next
268
+ end
269
+ services.keys.map(&:to_s).sort.each do |name|
270
+ next if name.strip.empty?
271
+
272
+ entries << {
273
+ "subject" => "compose_service:#{normalize_subject(name)}",
274
+ "scope" => { "repository" => "repo-default", "deployment_descriptor" => path },
275
+ "evidence" => {
276
+ "source_type" => "config_identifier",
277
+ "path" => path,
278
+ "git_revision" => revision,
279
+ "sha256" => digest,
280
+ "locator" => "services.#{name}",
281
+ "extractor" => "docker_compose_services_v1"
282
+ }
283
+ }
284
+ end
285
+ files << { "path" => path, "status" => "scanned", "extractor" => "docker_compose_services_v1", "sha256" => digest }
286
+ rescue Psych::Exception
287
+ files << { "path" => path, "status" => "failed", "extractor" => "docker_compose_services_v1", "error" => "parse_error" }
288
+ rescue StandardError => error
289
+ files << { "path" => path, "status" => "failed", "extractor" => "docker_compose_services_v1", "error" => error.message }
290
+ end
291
+ end
292
+ [files.sort_by { |file| file["path"] }, entries]
293
+ end
294
+
295
+ def stable_evidence(entries)
296
+ entries.sort_by { |entry| [entry["path"], entry["extractor"], entry["sha256"]] }.uniq
297
+ end
298
+
299
+ def claim_id(kind, assertion, subject, scope)
300
+ "CTX-#{sha256(canonical_json({ "kind" => kind, "assertion" => assertion, "subject" => subject, "scope" => scope }))[0, 16]}"
301
+ end
302
+
303
+ def grouped_claims(kind, assertion, default_scope, entries)
304
+ entries.group_by { |entry| [entry.fetch("subject"), canonical_json(entry.fetch("scope", default_scope))] }.map do |(subject, scope_json), group|
305
+ scope = JSON.parse(scope_json)
306
+ evidence = stable_evidence(group.map do |entry|
307
+ item = entry.fetch("evidence").dup
308
+ item["declared_version"] = entry["version"] if entry.key?("version") && !entry["version"].nil?
309
+ item
310
+ end)
311
+ {
312
+ "id" => claim_id(kind, assertion, subject, scope),
313
+ "kind" => kind,
314
+ "subject" => subject,
315
+ "assertion" => assertion,
316
+ "scope" => scope,
317
+ "evidence" => evidence,
318
+ "collection_state" => "proven"
319
+ }
320
+ end
321
+ end
322
+
323
+ def coverage(subject_class, scope, rule, status, files: [], reason: nil)
324
+ result = {
325
+ "subject_class" => subject_class,
326
+ "scope" => scope,
327
+ "rule" => rule,
328
+ "status" => status,
329
+ "files" => files
330
+ }
331
+ result["reason"] = reason if reason
332
+ result
333
+ end
334
+
335
+ def snapshot_for(root, repository_revision)
336
+ scope = { "repository" => "repo-default" }
337
+ if repository_revision.empty?
338
+ failure = { "path" => "<repository>", "status" => "failed", "error" => "git_revision_unavailable" }
339
+ return [
340
+ [],
341
+ [
342
+ coverage("dependency_declaration", scope, "manifests-v2", "incomplete", files: [failure]),
343
+ coverage("platform_declaration", scope, "miniprogram-app-json-v1", "unknown", reason: "git_revision_unavailable"),
344
+ coverage("resource_binding", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled"),
345
+ coverage("integration_use", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled"),
346
+ coverage("job_use", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled")
347
+ ],
348
+ { "dependency_declaration" => [failure], "platform_declaration" => [] }
349
+ ]
350
+ end
351
+
352
+ begin
353
+ dependency_files, dependency_entries = dependency_discovery(root, repository_revision)
354
+ rescue StandardError => error
355
+ dependency_files = [{ "path" => "<repository>", "status" => "failed", "error" => error.message }]
356
+ dependency_entries = []
357
+ end
358
+ begin
359
+ platform_files, platform_entries = platform_discovery(root, repository_revision)
360
+ rescue StandardError => error
361
+ platform_files = [{ "path" => "<repository>", "status" => "failed", "error" => error.message }]
362
+ platform_entries = []
363
+ end
364
+ begin
365
+ resource_files, resource_entries = compose_discovery(root, repository_revision)
366
+ rescue StandardError => error
367
+ resource_files = [{ "path" => "<repository>", "status" => "failed", "error" => error.message }]
368
+ resource_entries = []
369
+ end
370
+ dependency_status = dependency_files.any? { |file| file["status"] == "failed" } ? "incomplete" : "complete"
371
+ platform_status = platform_files.any? { |file| file["status"] == "failed" } ? "incomplete" : "partial"
372
+ resource_status = resource_files.any? { |file| file["status"] == "failed" } ? "incomplete" : "partial"
373
+ claims = grouped_claims("dependency_declaration", "declared", scope, dependency_entries) +
374
+ grouped_claims("platform_declaration", "declared", scope, platform_entries) +
375
+ grouped_claims("resource_binding", "declared", scope, resource_entries)
376
+ coverages = [
377
+ coverage("dependency_declaration", scope, "manifests-v2", dependency_status, files: dependency_files),
378
+ coverage("platform_declaration", scope, "miniprogram-app-json-v1", platform_status, files: platform_files, reason: "only_miniprogram_app_json_is_supported"),
379
+ coverage("resource_binding", scope, "docker-compose-services-v1", resource_status, files: resource_files, reason: "only_compose_service_names_are_supported"),
380
+ coverage("integration_use", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled"),
381
+ coverage("job_use", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled")
382
+ ]
383
+ [claims, coverages, {
384
+ "dependency_declaration" => dependency_files,
385
+ "platform_declaration" => platform_files,
386
+ "resource_binding" => resource_files
387
+ }]
388
+ end
389
+
390
+ def snapshot_paths(root)
391
+ Dir.glob(File.join(root, HISTORY_DIR, "*.json")).sort_by { |path| File.basename(path, ".json").to_i }
392
+ end
393
+
394
+ def next_snapshot_revision(root)
395
+ latest = snapshot_paths(root).map { |path| File.basename(path, ".json").to_i }.max || 0
396
+ latest + 1
397
+ end
398
+
399
+ def matching_snapshot(root, repository_revision, input_fingerprint)
400
+ snapshot_paths(root).each do |path|
401
+ snapshot = JSON.parse(File.read(path))
402
+ next unless snapshot["schema_version"] == SCHEMA_VERSION
403
+ next unless snapshot["collector_version"] == COLLECTOR_VERSION
404
+ next unless snapshot["repository_revision"] == repository_revision
405
+ next unless snapshot["input_fingerprint"] == input_fingerprint
406
+
407
+ return [snapshot, path]
408
+ rescue JSON::ParserError
409
+ raise ArgumentError, "static runtime-context history is invalid JSON: #{path}"
410
+ end
411
+ nil
412
+ end
413
+
414
+ def relative_path(root, path)
415
+ path.sub("#{root}/", "")
416
+ end
417
+
418
+ def collect(root)
419
+ repository_revision = git_revision(root)
420
+ claims, coverages, discovery = snapshot_for(root, repository_revision)
421
+ claims = claims.sort_by { |claim| [claim["kind"], claim["subject"], claim["id"]] }
422
+ input_fingerprint = sha256(canonical_json({
423
+ "collector_version" => COLLECTOR_VERSION,
424
+ "repository_revision" => repository_revision,
425
+ "coverage" => coverages,
426
+ "claims" => claims,
427
+ "discovery" => discovery
428
+ }))
429
+ if (existing = matching_snapshot(root, repository_revision, input_fingerprint))
430
+ snapshot, path = existing
431
+ DevflowControlIO.atomic_write(File.join(root, POINTER), JSON.pretty_generate({ "current_revision" => snapshot["snapshot_revision"], "path" => relative_path(root, path) }) + "\n")
432
+ DevflowControlIO.append_audit(File.join(root, AUDIT_PATH), {
433
+ "event" => "snapshot_reused", "snapshot_revision" => snapshot["snapshot_revision"], "repository_revision" => repository_revision,
434
+ "input_fingerprint" => input_fingerprint, "at" => Time.now.utc.iso8601
435
+ })
436
+ return [snapshot, true]
437
+ end
438
+
439
+ revision = next_snapshot_revision(root)
440
+ snapshot = {
441
+ "schema_version" => SCHEMA_VERSION,
442
+ "context_id" => "repository-static",
443
+ "snapshot_revision" => revision,
444
+ "repository_revision" => repository_revision,
445
+ "collected_at" => Time.now.utc.iso8601,
446
+ "collector_version" => COLLECTOR_VERSION,
447
+ "input_fingerprint" => input_fingerprint,
448
+ "claims" => claims,
449
+ "coverage" => coverages,
450
+ "discovery" => discovery
451
+ }
452
+ history = File.join(root, HISTORY_DIR, "#{revision}.json")
453
+ DevflowControlIO.atomic_create(history, JSON.pretty_generate(snapshot) + "\n")
454
+ DevflowControlIO.atomic_write(File.join(root, POINTER), JSON.pretty_generate({ "current_revision" => revision, "path" => relative_path(root, history) }) + "\n")
455
+ DevflowControlIO.append_audit(File.join(root, AUDIT_PATH), {
456
+ "event" => "snapshot_collected", "snapshot_revision" => revision, "repository_revision" => repository_revision,
457
+ "input_fingerprint" => input_fingerprint, "claims_count" => claims.length, "at" => Time.now.utc.iso8601
458
+ })
459
+ DevflowControlIO.register_control_artifact(root, {
460
+ "path" => relative_path(root, history), "type" => "static_runtime_context_snapshot",
461
+ "produced_by" => "core", "snapshot_revision" => revision, "repository_revision" => repository_revision,
462
+ "input_fingerprint" => input_fingerprint
463
+ })
464
+ [snapshot, false]
465
+ end
466
+
467
+ def inspect(root)
468
+ pointer_path = File.join(root, POINTER)
469
+ return { "ok" => true, "pointer_exists" => false, "current_revision" => 0 } unless File.exist?(pointer_path)
470
+
471
+ pointer = JSON.parse(File.read(pointer_path))
472
+ history = File.join(root, pointer.fetch("path"))
473
+ snapshot = JSON.parse(File.read(history))
474
+ {
475
+ "ok" => true,
476
+ "pointer_exists" => true,
477
+ "current_revision" => snapshot["snapshot_revision"],
478
+ "repository_revision" => snapshot["repository_revision"],
479
+ "claims_count" => Array(snapshot["claims"]).length,
480
+ "path" => pointer["path"]
481
+ }
482
+ rescue JSON::ParserError, KeyError => error
483
+ raise ArgumentError, "static runtime-context pointer is invalid: #{error.message}"
484
+ end
485
+ end
486
+
487
+ if __FILE__ == $PROGRAM_NAME
488
+ options = { json: false }
489
+ command = ARGV.shift
490
+ OptionParser.new do |o|
491
+ o.on("--json") { options[:json] = true }
492
+ end.parse!
493
+ root = Dir.pwd
494
+ lock = command == "collect" ? DevflowControlIO.acquire_project_lock(root) : nil
495
+ begin
496
+ result =
497
+ case command
498
+ when "collect"
499
+ snapshot, reused = StaticRuntimeContextCollector.collect(root)
500
+ {
501
+ "ok" => true,
502
+ "snapshot_revision" => snapshot["snapshot_revision"],
503
+ "repository_revision" => snapshot["repository_revision"],
504
+ "claims_count" => snapshot["claims"].length,
505
+ "reused" => reused
506
+ }
507
+ when "inspect"
508
+ StaticRuntimeContextCollector.inspect(root)
509
+ else
510
+ raise ArgumentError, "command must be collect or inspect"
511
+ end
512
+ rescue StandardError => error
513
+ result = { "ok" => false, "reason" => "static_runtime_context_collector_failed", "message" => error.message }
514
+ ensure
515
+ DevflowControlIO.release_lock(lock)
516
+ end
517
+ puts JSON.pretty_generate(result)
518
+ end
@@ -7,6 +7,7 @@ require "yaml"
7
7
  require_relative "control_io"
8
8
  require_relative "phase_ownership"
9
9
  require_relative "requirement_graph"
10
+ require_relative "architecture_feasibility"
10
11
 
11
12
  module WorkflowStateValidator
12
13
  REQUIRED_STATE_FIELDS = %w[
@@ -78,7 +79,7 @@ module WorkflowStateValidator
78
79
  validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
79
80
  validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
80
81
  graph_gate = workflow.dig("governance", "requirement_graph_gate").to_s
81
- governed_steps = %w[requirement-baseline api-contract integration-verify change-plan execute verify]
82
+ governed_steps = %w[requirement-baseline api-contract architecture-feasibility integration-verify change-plan execute verify]
82
83
  # A historical completed state is never rolled back solely because it has
83
84
  # no v2 graph/evidence. Its first new governed step, however, must satisfy
84
85
  # the new contract before it can advance.
@@ -378,6 +379,8 @@ module WorkflowStateValidator
378
379
  validate_requirement_feasibility_review_gate(issues, step, data)
379
380
  validate_requirement_baseline_gate(issues, step, data)
380
381
  validate_api_contract_gate(issues, step, data)
382
+ validate_architecture_feasibility_gate(issues, step, data)
383
+ validate_architecture_consumption_gate(issues, root, state, step, data)
381
384
  validate_api_call_guide_gate(issues, step, data)
382
385
  validate_frontend_tech_spec_gate(issues, step, data)
383
386
  validate_api_supplement_gate(issues, step, data)
@@ -512,6 +515,85 @@ module WorkflowStateValidator
512
515
  end
513
516
  end
514
517
 
518
+ # P2 is intentionally graduated: only a structurally-derived full
519
+ # assessment fail-closes. Lightweight incomplete work remains visible to its
520
+ # technical-spec consumers rather than forcing every small feature through
521
+ # an invented NFR exercise.
522
+ def validate_architecture_feasibility_gate(issues, step, data)
523
+ return unless step["id"] == "architecture-feasibility"
524
+
525
+ level = data["assessment_level"]
526
+ status = data["assessment_status"]
527
+ comparison = data["comparison_status"]
528
+ unless %w[not_triggered lightweight full].include?(level)
529
+ issues << issue("error", "architecture_assessment_level_invalid", "Architecture assessment level is invalid", step: step["id"], level: level)
530
+ return
531
+ end
532
+ unless %w[ready incomplete].include?(status)
533
+ issues << issue("error", "architecture_assessment_status_invalid", "Architecture assessment status is invalid", step: step["id"], status: status)
534
+ return
535
+ end
536
+ unless %w[not_required missing awaiting_user_confirmation missing_risk awaiting_risk_confirmation confirmed].include?(comparison)
537
+ issues << issue("error", "architecture_comparison_status_invalid", "Architecture comparison status is invalid", step: step["id"], comparison_status: comparison)
538
+ return
539
+ end
540
+ return unless level == "full"
541
+ return if status == "ready" && comparison == "confirmed"
542
+
543
+ issues << issue(
544
+ "error",
545
+ "architecture_full_assessment_incomplete",
546
+ "A full architecture assessment requires ready inputs plus confirmed decision and risk treatment before technical specifications.",
547
+ step: step["id"],
548
+ assessment_status: status,
549
+ comparison_status: comparison
550
+ )
551
+ end
552
+
553
+ # A technical spec does not merely claim that it considered architecture. It
554
+ # pins the exact Core-derived brief it consumed. Compare the pin both to the
555
+ # brief and to the current graph/context so an upstream edit cannot leave a
556
+ # convincing but obsolete spec looking fresh.
557
+ def validate_architecture_consumption_gate(issues, root, state, step, data)
558
+ return unless %w[frontend-tech-spec backend-tech-spec].include?(step["id"])
559
+
560
+ feature = state["current_feature"]
561
+ brief_path = File.join(root, ".planning", "features", feature, "architecture-brief.meta.json")
562
+ unless File.file?(brief_path)
563
+ issues << issue("error", "architecture_brief_meta_missing", "Technical spec cannot be validated without the architecture brief meta", step: step["id"], path: brief_path)
564
+ return
565
+ end
566
+ brief = JSON.parse(File.read(brief_path))
567
+ expected = {
568
+ "architecture_brief_revision" => brief["brief_revision"],
569
+ "architecture_graph_revision" => brief["graph_revision"],
570
+ "architecture_static_context_snapshot_revision" => brief["static_context_snapshot_revision"],
571
+ "architecture_comparison_status" => brief["comparison_status"]
572
+ }
573
+ expected.each do |field, value|
574
+ next if data[field] == value
575
+
576
+ issues << issue("error", "architecture_consumption_pin_mismatch", "Technical spec architecture pin differs from the Core-derived brief", step: step["id"], field: field, spec_value: data[field], brief_value: value)
577
+ end
578
+
579
+ graph, = RequirementGraph.load_graph(root, feature)
580
+ if brief["graph_revision"] != graph["revision"]
581
+ issues << issue("error", "architecture_brief_graph_stale", "Architecture brief was derived from a stale graph revision", step: step["id"], brief_revision: brief["graph_revision"], graph_revision: graph["revision"])
582
+ end
583
+ current_context = ArchitectureFeasibility.safe_static_context(root)
584
+ current_context_revision = current_context["available"] ? current_context["snapshot_revision"] : nil
585
+ if brief["static_context_snapshot_revision"] != current_context_revision
586
+ issues << issue("error", "architecture_brief_context_stale", "Architecture brief was derived from a stale static-context snapshot", step: step["id"], brief_revision: brief["static_context_snapshot_revision"], context_revision: current_context_revision)
587
+ end
588
+ if brief["assessment_level"] == "full" && brief["comparison_status"] != "confirmed"
589
+ issues << issue("error", "architecture_brief_full_unconfirmed", "Technical spec cannot consume an unconfirmed full architecture assessment", step: step["id"], comparison_status: brief["comparison_status"])
590
+ end
591
+ rescue JSON::ParserError => error
592
+ issues << issue("error", "architecture_brief_meta_invalid", error.message, step: step["id"], path: brief_path)
593
+ rescue ArgumentError => error
594
+ issues << issue("error", "architecture_brief_graph_invalid", error.message, step: step["id"])
595
+ end
596
+
515
597
  def validate_api_call_guide_gate(issues, step, data)
516
598
  return unless step["id"] == "api-call-guide"
517
599