@guanlin4924/devflow-pi 0.1.40 → 0.1.48
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/bin/apply-opencode-go-session-hotfix.mjs +61 -0
- package/package.json +2 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
- package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
- package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/runtime.ts +53 -22
|
@@ -9,6 +9,7 @@ require "json"
|
|
|
9
9
|
require "optparse"
|
|
10
10
|
require "pathname"
|
|
11
11
|
require "set"
|
|
12
|
+
require "yaml"
|
|
12
13
|
require_relative "control_io"
|
|
13
14
|
require_relative "architecture_constraints"
|
|
14
15
|
|
|
@@ -54,6 +55,22 @@ module RequirementGraph
|
|
|
54
55
|
File.dirname(path)
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
def active_feature(root)
|
|
59
|
+
state_path = File.join(root, ".planning", "STATE.md")
|
|
60
|
+
return nil unless File.file?(state_path)
|
|
61
|
+
front = File.read(state_path).match(/\A---\n(.*?)\n---/m)&.[](1)
|
|
62
|
+
return nil unless front
|
|
63
|
+
YAML.safe_load(front, permitted_classes: [], aliases: false)["current_feature"]
|
|
64
|
+
rescue Psych::Exception
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def assert_active_feature!(root, feature)
|
|
69
|
+
current = active_feature(root)
|
|
70
|
+
return if current.nil? || current == feature
|
|
71
|
+
raise ArgumentError, "feature is not active: requested=#{feature}, current=#{current}; bootstrap the feature before writing its requirement graph"
|
|
72
|
+
end
|
|
73
|
+
|
|
57
74
|
def empty_graph(feature)
|
|
58
75
|
{ "schema_version" => SCHEMA_VERSION, "feature" => feature, "revision" => 0,
|
|
59
76
|
"nodes" => [], "edges" => [], "stale" => [], "views" => {}, "audit" => [] }
|
|
@@ -373,15 +390,76 @@ module RequirementGraph
|
|
|
373
390
|
node["kind"] == "test_case" && node["status"] == "confirmed" && !Array(graph["stale"]).any? { |entry| entry.is_a?(Hash) && entry["node_id"] == node["id"] }
|
|
374
391
|
end
|
|
375
392
|
|
|
393
|
+
TEST_PREREQUISITE_FIELDS = %w[acceptance_refs requirement_refs api_refs workload_refs risk_refs metric_refs read_model_refs].freeze
|
|
394
|
+
CP3_CONFIRMABLE_PREREQUISITE_KINDS = %w[source behavior requirement rule boundary acceptance].freeze
|
|
395
|
+
|
|
396
|
+
def test_prerequisite_nodes(graph)
|
|
397
|
+
by_id = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["id"].is_a?(String) }.to_h { |node| [node["id"], node] }
|
|
398
|
+
ids = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }.flat_map do |node|
|
|
399
|
+
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
400
|
+
TEST_PREREQUISITE_FIELDS.flat_map { |field| Array(data[field]) }
|
|
401
|
+
end.compact.uniq
|
|
402
|
+
|
|
403
|
+
# A reviewed TC may point at a requirement whose own source/behavior facts
|
|
404
|
+
# were introduced by a legacy backfill. Include that small dependency
|
|
405
|
+
# closure so the user sees every fact that the CP-3 recovery will confirm.
|
|
406
|
+
queue = ids.dup
|
|
407
|
+
until queue.empty?
|
|
408
|
+
node = by_id[queue.shift]
|
|
409
|
+
next unless node && node["kind"] == "requirement"
|
|
410
|
+
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
411
|
+
dependencies = Array(data["source_refs"]) + Array(data["behavior_ref"])
|
|
412
|
+
dependencies.each do |ref|
|
|
413
|
+
next unless ref.is_a?(String) && !ids.include?(ref)
|
|
414
|
+
ids << ref
|
|
415
|
+
queue << ref
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
ids.map { |id| by_id[id] }.compact
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def candidate_test_prerequisites(graph)
|
|
422
|
+
test_prerequisite_nodes(graph).select { |node| node["status"] != "confirmed" }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def confirm_test_prerequisites!(graph, cp_record)
|
|
426
|
+
candidates = candidate_test_prerequisites(graph)
|
|
427
|
+
unsupported = candidates.reject { |node| CP3_CONFIRMABLE_PREREQUISITE_KINDS.include?(node["kind"]) }
|
|
428
|
+
if unsupported.any?
|
|
429
|
+
return {
|
|
430
|
+
"ok" => false,
|
|
431
|
+
"reason" => "test_prerequisite_requires_earlier_gate",
|
|
432
|
+
"errors" => unsupported.map { |node| "#{node["id"]}: #{node["kind"]} must be confirmed by its owning checkpoint" }
|
|
433
|
+
}
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
order = { "source" => 0, "behavior" => 1, "rule" => 2, "boundary" => 3, "requirement" => 4, "acceptance" => 5 }
|
|
437
|
+
confirmed = []
|
|
438
|
+
candidates.sort_by { |node| [order.fetch(node["kind"], 99), node["id"]] }.each do |node|
|
|
439
|
+
confirm(graph, node["id"], { "cp_record" => cp_record })
|
|
440
|
+
confirmed << node["id"]
|
|
441
|
+
end
|
|
442
|
+
{ "ok" => true, "confirmed_prerequisite_ids" => confirmed }
|
|
443
|
+
rescue ArgumentError => error
|
|
444
|
+
{ "ok" => false, "reason" => "test_prerequisite_confirmation_failed", "message" => error.message }
|
|
445
|
+
end
|
|
446
|
+
|
|
376
447
|
# CP-3 is the single freeze point. Keep it here instead of in a Skill so the
|
|
377
448
|
# graph revision, TC statuses, views and audit entry commit atomically under
|
|
378
449
|
# the caller's control-plane lock.
|
|
379
|
-
def freeze_test_cases!(root, graph, cp_record)
|
|
450
|
+
def freeze_test_cases!(root, graph, cp_record, confirm_prerequisites: false, persist: true)
|
|
380
451
|
cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
|
|
381
452
|
return { "ok" => false, "reason" => "test_suite_empty", "message" => "cannot freeze an empty TC suite" } if cases.empty?
|
|
382
453
|
return { "ok" => false, "reason" => "test_suite_already_frozen", "message" => "confirmed test cases require a new candidate suite" } if cases.any? { |node| node["status"] == "confirmed" }
|
|
383
454
|
return { "ok" => false, "reason" => "test_suite_graph_stale", "message" => "cannot freeze TC suite while the requirement graph has stale downstream nodes" } if Array(graph["stale"]).any?
|
|
384
455
|
|
|
456
|
+
prerequisite_confirmation = if confirm_prerequisites
|
|
457
|
+
confirm_test_prerequisites!(graph, cp_record)
|
|
458
|
+
else
|
|
459
|
+
{ "ok" => true, "confirmed_prerequisite_ids" => [] }
|
|
460
|
+
end
|
|
461
|
+
return prerequisite_confirmation unless prerequisite_confirmation["ok"]
|
|
462
|
+
|
|
385
463
|
checkers = checker_results(graph)
|
|
386
464
|
errors = checkers.fetch("test-coverage-check", []).dup
|
|
387
465
|
errors.concat(checkers.fetch("test-obligation-check", []))
|
|
@@ -395,12 +473,26 @@ module RequirementGraph
|
|
|
395
473
|
clear_stale_for!(graph, node["id"])
|
|
396
474
|
end
|
|
397
475
|
graph["revision"] += 1
|
|
476
|
+
event = {
|
|
477
|
+
"event" => "test_suite_freeze",
|
|
478
|
+
"node_ids" => cases.map { |node| node["id"] },
|
|
479
|
+
"confirmed_prerequisite_ids" => prerequisite_confirmation["confirmed_prerequisite_ids"],
|
|
480
|
+
"cp_record" => cp_record
|
|
481
|
+
}
|
|
482
|
+
persist_test_suite_freeze!(root, graph, event) if persist
|
|
483
|
+
path, = graph_path(root, graph["feature"])
|
|
484
|
+
{ "ok" => true, "path" => path.sub("#{File.realpath(root)}/", ""), "graph_revision" => graph["revision"],
|
|
485
|
+
"frozen_case_ids" => cases.map { |node| node["id"] },
|
|
486
|
+
"confirmed_prerequisite_ids" => prerequisite_confirmation["confirmed_prerequisite_ids"],
|
|
487
|
+
"tests_view" => persist ? graph.dig("views", "tests") : view_payload(graph, "tests"),
|
|
488
|
+
"audit_event" => event }
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def persist_test_suite_freeze!(root, graph, event)
|
|
398
492
|
write_views!(root, graph, stale: false)
|
|
399
493
|
path, = graph_path(root, graph["feature"])
|
|
400
|
-
append_audit!(root, graph,
|
|
494
|
+
append_audit!(root, graph, event)
|
|
401
495
|
DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
|
|
402
|
-
{ "ok" => true, "path" => path.sub("#{File.realpath(root)}/", ""), "graph_revision" => graph["revision"],
|
|
403
|
-
"frozen_case_ids" => cases.map { |node| node["id"] }, "tests_view" => graph.dig("views", "tests") }
|
|
404
496
|
end
|
|
405
497
|
|
|
406
498
|
# The matrix compiler is Core-owned and deterministic; this importer is the
|
|
@@ -854,6 +946,33 @@ module RequirementGraph
|
|
|
854
946
|
results
|
|
855
947
|
end
|
|
856
948
|
|
|
949
|
+
# Requirement construction deliberately precedes acceptance-test design.
|
|
950
|
+
# These checkers describe obligations that must be materialized later; they
|
|
951
|
+
# are diagnostics, not baseline blockers. Keep them explicit in the output
|
|
952
|
+
# so callers cannot mistake a deferred obligation for a current failure.
|
|
953
|
+
DEFERRED_CHECKERS = %w[test-coverage-check test-obligation-check].freeze
|
|
954
|
+
|
|
955
|
+
def validation_report(graph)
|
|
956
|
+
checkers = checker_results(graph)
|
|
957
|
+
blocking = []
|
|
958
|
+
checkers.each do |id, issues|
|
|
959
|
+
next if DEFERRED_CHECKERS.include?(id)
|
|
960
|
+
blocking.concat(issues)
|
|
961
|
+
end
|
|
962
|
+
Array(graph["nodes"]).each do |node|
|
|
963
|
+
next unless node.is_a?(Hash) && high_risk_by_structure?(node)
|
|
964
|
+
blocking << "#{node["id"]}: derived high-risk node must be confirmed and provable" unless node["status"] == "confirmed"
|
|
965
|
+
end
|
|
966
|
+
{
|
|
967
|
+
"blocking_errors" => blocking.uniq,
|
|
968
|
+
"deferred_checkers" => DEFERRED_CHECKERS.to_h { |id| [id, Array(checkers[id])] }
|
|
969
|
+
}
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
def candidate_validation_errors(graph)
|
|
973
|
+
validation_report(graph).fetch("blocking_errors")
|
|
974
|
+
end
|
|
975
|
+
|
|
857
976
|
def validate(graph)
|
|
858
977
|
errors = []
|
|
859
978
|
ids = []
|
|
@@ -872,11 +991,7 @@ module RequirementGraph
|
|
|
872
991
|
# Test coverage becomes enforced only at acceptance-test-design and later
|
|
873
992
|
# workflow steps. Baseline construction itself must remain valid before
|
|
874
993
|
# any derived TC candidates exist.
|
|
875
|
-
|
|
876
|
-
Array(graph["nodes"]).each do |node|
|
|
877
|
-
next unless node.is_a?(Hash) && high_risk_by_structure?(node)
|
|
878
|
-
errors << "#{node["id"]}: derived high-risk node must be confirmed and provable" unless node["status"] == "confirmed"
|
|
879
|
-
end
|
|
994
|
+
errors.concat(validation_report(graph).fetch("blocking_errors"))
|
|
880
995
|
errors.uniq
|
|
881
996
|
end
|
|
882
997
|
|
|
@@ -975,15 +1090,23 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
975
1090
|
opts.on("--json") { options[:json] = true }
|
|
976
1091
|
end.parse!
|
|
977
1092
|
root = Dir.pwd
|
|
1093
|
+
mutating_command = (%w[propose import-test-cases confirm].include?(command) && options[:write]) || (command == "context" && options[:write])
|
|
978
1094
|
lock = %w[propose import-test-cases context confirm].include?(command) && options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
979
1095
|
begin
|
|
1096
|
+
RequirementGraph.assert_active_feature!(root, options[:feature]) if mutating_command
|
|
980
1097
|
graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
|
|
981
1098
|
result = case command
|
|
982
1099
|
when "inspect"
|
|
983
|
-
|
|
1100
|
+
report = RequirementGraph.validation_report(graph)
|
|
1101
|
+
{ "ok" => report.fetch("blocking_errors").empty?, "path" => relative, "graph" => graph,
|
|
1102
|
+
"errors" => report.fetch("blocking_errors"), "checkers" => RequirementGraph.checker_results(graph),
|
|
1103
|
+
"deferred_checkers" => report.fetch("deferred_checkers") }
|
|
984
1104
|
when "validate"
|
|
985
|
-
|
|
986
|
-
|
|
1105
|
+
report = RequirementGraph.validation_report(graph)
|
|
1106
|
+
errors = report.fetch("blocking_errors")
|
|
1107
|
+
{ "ok" => errors.empty?, "path" => relative, "revision" => graph["revision"], "errors" => errors,
|
|
1108
|
+
"checkers" => RequirementGraph.checker_results(graph), "deferred_checkers" => report.fetch("deferred_checkers"),
|
|
1109
|
+
"stale" => graph["stale"] }
|
|
987
1110
|
when "context"
|
|
988
1111
|
if options[:read]
|
|
989
1112
|
pkg = RequirementGraph.load_context_package(root, graph["feature"], options[:slice_id])
|
|
@@ -1019,7 +1142,8 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
1019
1142
|
RequirementGraph.append_audit!(root, candidate, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
|
|
1020
1143
|
DevflowControlIO.atomic_write(path, JSON.pretty_generate(candidate) + "\n")
|
|
1021
1144
|
end
|
|
1022
|
-
|
|
1145
|
+
report = RequirementGraph.validation_report(candidate)
|
|
1146
|
+
{ "ok" => true, "path" => relative, "written" => options[:write], "revision" => options[:write] ? candidate["revision"] : graph["revision"], "applied" => applied, "validation_errors" => report.fetch("blocking_errors"), "checkers" => RequirementGraph.checker_results(candidate), "deferred_checkers" => report.fetch("deferred_checkers"), "views" => candidate["views"] }
|
|
1023
1147
|
end
|
|
1024
1148
|
when "import-test-cases"
|
|
1025
1149
|
raise ArgumentError, "--write is required" unless options[:write]
|
|
@@ -1045,7 +1169,8 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
1045
1169
|
RequirementGraph.write_views!(root, graph, stale: stale)
|
|
1046
1170
|
RequirementGraph.append_audit!(root, graph, { "event" => "graph_confirm", "node_id" => options[:node_id] })
|
|
1047
1171
|
DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
|
|
1048
|
-
|
|
1172
|
+
report = RequirementGraph.validation_report(graph)
|
|
1173
|
+
{ "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => report.fetch("blocking_errors"), "checkers" => RequirementGraph.checker_results(graph), "deferred_checkers" => report.fetch("deferred_checkers"), "views" => graph["views"] }
|
|
1049
1174
|
end
|
|
1050
1175
|
else
|
|
1051
1176
|
{ "ok" => false, "reason" => "invalid_command", "message" => "command must be propose, import-test-cases, inspect, validate, context, or confirm" }
|
package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Core-owned, idempotent requirement-graph backfill. Renumbers existing node
|
|
5
|
+
# IDs (and every in-graph string reference to them), appends seed nodes/edges,
|
|
6
|
+
# then regenerates all derived views through the same writer the graph normally
|
|
7
|
+
# uses. It never hand-edits JSON: every mutation happens under the project lock
|
|
8
|
+
# and is persisted via DevflowControlIO.atomic_write.
|
|
9
|
+
require "json"
|
|
10
|
+
require "optparse"
|
|
11
|
+
require "set"
|
|
12
|
+
require_relative "requirement_graph"
|
|
13
|
+
require_relative "control_io"
|
|
14
|
+
|
|
15
|
+
options = { root: ".", feature: nil, seed: nil, write: false, json: false }
|
|
16
|
+
OptionParser.new do |opts|
|
|
17
|
+
opts.banner = "Usage: requirement_graph_backfill.rb --feature NAME --seed PATH [--write]"
|
|
18
|
+
opts.on("--root PATH") { |value| options[:root] = value }
|
|
19
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
20
|
+
opts.on("--seed PATH") { |value| options[:seed] = value }
|
|
21
|
+
opts.on("--write") { options[:write] = true }
|
|
22
|
+
opts.on("--json") { options[:json] = true }
|
|
23
|
+
end.parse!
|
|
24
|
+
|
|
25
|
+
def deep_replace(value, from, to)
|
|
26
|
+
case value
|
|
27
|
+
when Hash
|
|
28
|
+
value.each { |key, item| value[key] = deep_replace(item, from, to) }
|
|
29
|
+
value
|
|
30
|
+
when Array
|
|
31
|
+
value.map { |item| deep_replace(item, from, to) }
|
|
32
|
+
when String
|
|
33
|
+
value.include?(from) ? value.gsub(from, to) : value
|
|
34
|
+
else
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
root = File.realpath(options[:root])
|
|
40
|
+
lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
|
|
44
|
+
raise ArgumentError, "--seed is required" if options[:seed].to_s.empty?
|
|
45
|
+
seed_path = File.expand_path(options[:seed], root)
|
|
46
|
+
raise ArgumentError, "seed file is missing: #{options[:seed]}" unless File.file?(seed_path)
|
|
47
|
+
|
|
48
|
+
seed = JSON.parse(File.read(seed_path))
|
|
49
|
+
raise ArgumentError, "seed must be an object" unless seed.is_a?(Hash)
|
|
50
|
+
renumber = seed["renumber"] || {}
|
|
51
|
+
raise ArgumentError, "renumber must be an object" unless renumber.is_a?(Hash)
|
|
52
|
+
new_nodes = seed["nodes"] || []
|
|
53
|
+
new_edges = seed["edges"] || []
|
|
54
|
+
raise ArgumentError, "nodes must be an array" unless new_nodes.is_a?(Array)
|
|
55
|
+
raise ArgumentError, "edges must be an array" unless new_edges.is_a?(Array)
|
|
56
|
+
|
|
57
|
+
graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
|
|
58
|
+
existing_ids = graph["nodes"].map { |node| node["id"] }.to_set
|
|
59
|
+
|
|
60
|
+
# 1. Renumber: update the node id and every string reference (data, confirmation, edges).
|
|
61
|
+
renumber.each do |from, to|
|
|
62
|
+
raise ArgumentError, "renumber key is invalid: #{from}" unless from.is_a?(String) && from.match?(/\A[A-Z][A-Z0-9_]*-\d+\z/)
|
|
63
|
+
raise ArgumentError, "renumber target is invalid: #{to}" unless to.is_a?(String) && to.match?(/\A[A-Z][A-Z0-9_]*-\d+\z/)
|
|
64
|
+
raise ArgumentError, "renumber source node not found: #{from}" unless existing_ids.include?(from)
|
|
65
|
+
raise ArgumentError, "renumber target already exists: #{to}" if existing_ids.include?(to)
|
|
66
|
+
graph["nodes"].each do |node|
|
|
67
|
+
node["data"] = deep_replace(node["data"], from, to) if node["data"].is_a?(Hash)
|
|
68
|
+
node["confirmation"] = deep_replace(node["confirmation"], from, to) if node["confirmation"].is_a?(Hash)
|
|
69
|
+
node["id"] = to if node["id"] == from
|
|
70
|
+
end
|
|
71
|
+
graph["edges"].each do |edge|
|
|
72
|
+
edge["from"] = to if edge["from"] == from
|
|
73
|
+
edge["to"] = to if edge["to"] == from
|
|
74
|
+
end
|
|
75
|
+
existing_ids = graph["nodes"].map { |node| node["id"] }.to_set
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Fingerprints are content-addressed; recompute after the renumber mutates data.
|
|
79
|
+
graph["nodes"].each { |node| RequirementGraph.set_node_fingerprint!(node) }
|
|
80
|
+
|
|
81
|
+
# 2. Append seed nodes with explicit IDs (idempotency: refuse duplicates).
|
|
82
|
+
new_nodes.each do |node|
|
|
83
|
+
raise ArgumentError, "seed node must be an object" unless node.is_a?(Hash)
|
|
84
|
+
id = node["id"]
|
|
85
|
+
kind = node["kind"]
|
|
86
|
+
raise ArgumentError, "seed node requires id and kind" unless id.is_a?(String) && !id.empty? && kind.is_a?(String)
|
|
87
|
+
raise ArgumentError, "seed node kind is unsupported: #{kind}" unless RequirementGraph::KINDS.key?(kind)
|
|
88
|
+
raise ArgumentError, "seed node id already exists: #{id}" if existing_ids.include?(id)
|
|
89
|
+
node["status"] = "candidate" unless %w[candidate confirmed].include?(node["status"])
|
|
90
|
+
node["data"] = {} unless node["data"].is_a?(Hash)
|
|
91
|
+
RequirementGraph.set_node_fingerprint!(node)
|
|
92
|
+
graph["nodes"] << node
|
|
93
|
+
existing_ids << id
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# 3. Append edges, deduping against the existing edge set.
|
|
97
|
+
graph["edges"] = graph["edges"].uniq { |edge| [edge["from"], edge["to"], edge["relation"]] }
|
|
98
|
+
edge_added = 0
|
|
99
|
+
new_edges.each do |edge|
|
|
100
|
+
raise ArgumentError, "seed edge must be an object" unless edge.is_a?(Hash)
|
|
101
|
+
%w[from to relation].each do |key|
|
|
102
|
+
raise ArgumentError, "seed edge requires #{key}" unless edge[key].is_a?(String) && !edge[key].empty?
|
|
103
|
+
end
|
|
104
|
+
raise ArgumentError, "seed edge from/to must reference known nodes" unless existing_ids.include?(edge["from"]) && existing_ids.include?(edge["to"])
|
|
105
|
+
next if graph["edges"].any? { |existing| existing["from"] == edge["from"] && existing["to"] == edge["to"] && existing["relation"] == edge["relation"] }
|
|
106
|
+
|
|
107
|
+
graph["edges"] << edge
|
|
108
|
+
edge_added += 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# 4. Advance the graph revision once for the whole backfill.
|
|
112
|
+
graph["revision"] = graph["revision"].to_i + 1
|
|
113
|
+
|
|
114
|
+
# 5. Regenerate derived views and persist under the project lock.
|
|
115
|
+
if options[:write]
|
|
116
|
+
stale = Array(graph["stale"]).any?
|
|
117
|
+
RequirementGraph.write_views!(root, graph, stale: stale)
|
|
118
|
+
RequirementGraph.append_audit!(root, graph, {
|
|
119
|
+
"event" => "graph_backfill",
|
|
120
|
+
"renumber" => renumber,
|
|
121
|
+
"added_node_ids" => new_nodes.map { |node| node["id"] },
|
|
122
|
+
"added_edge_count" => edge_added
|
|
123
|
+
})
|
|
124
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
result = {
|
|
128
|
+
"ok" => true,
|
|
129
|
+
"written" => options[:write],
|
|
130
|
+
"path" => relative,
|
|
131
|
+
"revision" => graph["revision"],
|
|
132
|
+
"renumber" => renumber,
|
|
133
|
+
"added_node_ids" => new_nodes.map { |node| node["id"] },
|
|
134
|
+
"added_edge_count" => edge_added,
|
|
135
|
+
"validation_errors" => RequirementGraph.validate(graph)
|
|
136
|
+
}
|
|
137
|
+
puts JSON.pretty_generate(result)
|
|
138
|
+
rescue ArgumentError, JSON::ParserError => error
|
|
139
|
+
puts JSON.pretty_generate({ "ok" => false, "reason" => "requirement_graph_backfill_failed", "message" => error.message })
|
|
140
|
+
exit 1
|
|
141
|
+
ensure
|
|
142
|
+
DevflowControlIO.release_lock(lock)
|
|
143
|
+
end
|
|
@@ -13,6 +13,7 @@ require "securerandom"
|
|
|
13
13
|
require "time"
|
|
14
14
|
require "yaml"
|
|
15
15
|
require_relative "control_io"
|
|
16
|
+
require_relative "parallel_run"
|
|
16
17
|
|
|
17
18
|
DEFAULT_LEASE_SECONDS = 60
|
|
18
19
|
options = { json: false, write: false, lease_seconds: DEFAULT_LEASE_SECONDS }
|
|
@@ -46,6 +47,15 @@ def write_state(path, state, previous)
|
|
|
46
47
|
DevflowControlIO.atomic_write(path, "---\n#{front}---\n#{body}")
|
|
47
48
|
end
|
|
48
49
|
|
|
50
|
+
def rebase_parallel_run(root, state, lock)
|
|
51
|
+
feature = state["current_feature"]
|
|
52
|
+
return if feature.to_s.empty?
|
|
53
|
+
|
|
54
|
+
ParallelRun.rebase_state_revision(
|
|
55
|
+
root: root, feature: feature, state_revision: state.fetch("revision"), lock: lock
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
49
59
|
def valid_session_id?(value)
|
|
50
60
|
value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\z/)
|
|
51
61
|
end
|
|
@@ -151,6 +161,7 @@ result = catch(:transition_done) do
|
|
|
151
161
|
}
|
|
152
162
|
state["revision"] = revision + 1
|
|
153
163
|
write_state(state_path, state, raw)
|
|
164
|
+
rebase_parallel_run(root, state, lock)
|
|
154
165
|
end
|
|
155
166
|
result = { "ok" => true, "written" => options[:write], "rollover" => options[:write] ? state["rollover"] : payload.merge("handoff_sha256" => digest), "revision" => options[:write] ? revision + 1 : revision }
|
|
156
167
|
when "claim"
|
|
@@ -178,7 +189,7 @@ result = catch(:transition_done) do
|
|
|
178
189
|
end
|
|
179
190
|
if options[:write]
|
|
180
191
|
rollover["status"] = "claimed"; rollover["target_session_id"] = options[:target_session_id]; rollover["claimed_at"] = iso_now; rollover["lease_expires_at"] = lease_expiry(options[:lease_seconds])
|
|
181
|
-
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
192
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
|
|
182
193
|
end
|
|
183
194
|
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
184
195
|
when "renew"
|
|
@@ -194,7 +205,7 @@ result = catch(:transition_done) do
|
|
|
194
205
|
if options[:write]
|
|
195
206
|
rollover["lease_expires_at"] = lease_expiry(options[:lease_seconds])
|
|
196
207
|
rollover["last_renewed_at"] = iso_now
|
|
197
|
-
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
208
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
|
|
198
209
|
end
|
|
199
210
|
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
200
211
|
when "recover"
|
|
@@ -214,7 +225,7 @@ result = catch(:transition_done) do
|
|
|
214
225
|
end
|
|
215
226
|
if options[:write]
|
|
216
227
|
rollover["status"] = "prepared"; rollover["target_session_id"] = nil; rollover["claimed_at"] = nil; rollover.delete("lease_expires_at"); rollover.delete("last_renewed_at")
|
|
217
|
-
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
228
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
|
|
218
229
|
end
|
|
219
230
|
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
220
231
|
when "consume"
|
|
@@ -228,7 +239,7 @@ result = catch(:transition_done) do
|
|
|
228
239
|
end
|
|
229
240
|
if options[:write]
|
|
230
241
|
rollover["status"] = "consumed"; rollover["consumed_at"] = iso_now
|
|
231
|
-
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
242
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw); rebase_parallel_run(root, state, lock)
|
|
232
243
|
end
|
|
233
244
|
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
234
245
|
else
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "date"
|
|
5
|
+
require "digest"
|
|
5
6
|
require "fileutils"
|
|
6
7
|
require "json"
|
|
7
8
|
require "optparse"
|
|
@@ -109,6 +110,40 @@ def content_validity(root, path, checks)
|
|
|
109
110
|
{ "valid" => issues.empty?, "issues" => issues }
|
|
110
111
|
end
|
|
111
112
|
|
|
113
|
+
def questions_registry_entry(root, state, existing_by_path)
|
|
114
|
+
feature = state.fetch("current_feature", "").to_s
|
|
115
|
+
path = ".planning/features/#{feature}/questions.yaml"
|
|
116
|
+
absolute, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
117
|
+
exists = File.exist?(absolute)
|
|
118
|
+
entry = {
|
|
119
|
+
"path" => path,
|
|
120
|
+
"type" => "wireframe-questions",
|
|
121
|
+
"workflow_step" => "wireframe-proposal",
|
|
122
|
+
"produced_by" => "wireframe Skill",
|
|
123
|
+
"status" => "candidate",
|
|
124
|
+
"exists" => exists,
|
|
125
|
+
"content_valid" => false,
|
|
126
|
+
"content_issues" => exists ? nil : ["file_missing"],
|
|
127
|
+
"questions_status" => exists ? "present" : "unavailable"
|
|
128
|
+
}
|
|
129
|
+
if exists
|
|
130
|
+
entry["fingerprint"] = "sha256:#{Digest::SHA256.file(absolute).hexdigest}"
|
|
131
|
+
begin
|
|
132
|
+
questions = load_yaml_file(absolute)
|
|
133
|
+
list = questions.is_a?(Hash) ? Array(questions["questions"]) : []
|
|
134
|
+
entry["unresolved_count"] = list.count do |question|
|
|
135
|
+
question.is_a?(Hash) && question["kind"] == "blocking" && question["status"] == "open"
|
|
136
|
+
end
|
|
137
|
+
entry["content_valid"] = true
|
|
138
|
+
entry.delete("content_issues")
|
|
139
|
+
rescue StandardError => error
|
|
140
|
+
entry["questions_status"] = "invalid"
|
|
141
|
+
entry["content_issues"] = ["yaml_parse_error:#{error.message}"]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
entry
|
|
145
|
+
end
|
|
146
|
+
|
|
112
147
|
artifacts = collect_outputs(workflow).map do |step, output|
|
|
113
148
|
path = interpolate_path(output.fetch("path"), state)
|
|
114
149
|
existing = existing_by_path[path] || {}
|
|
@@ -145,6 +180,15 @@ artifacts = collect_outputs(workflow).map do |step, output|
|
|
|
145
180
|
}.reject { |_, value| value.nil? || value == [] }
|
|
146
181
|
end
|
|
147
182
|
|
|
183
|
+
questions_path = ".planning/features/#{state.fetch("current_feature", "")}/questions.yaml"
|
|
184
|
+
questions_entry = questions_registry_entry(root, state, existing_by_path)
|
|
185
|
+
question_index = artifacts.index { |artifact| artifact["path"] == questions_path }
|
|
186
|
+
if question_index
|
|
187
|
+
artifacts[question_index] = questions_entry
|
|
188
|
+
else
|
|
189
|
+
artifacts << questions_entry
|
|
190
|
+
end
|
|
191
|
+
|
|
148
192
|
registry = {
|
|
149
193
|
"schema_version" => 1,
|
|
150
194
|
"state_revision" => state.fetch("revision", 0),
|
|
@@ -154,7 +198,7 @@ registry = {
|
|
|
154
198
|
"feature" => state["current_feature"],
|
|
155
199
|
"phase" => state["current_phase"]
|
|
156
200
|
},
|
|
157
|
-
"status_values" => %w[planned generated approved completed passed pending_rerun stale failed],
|
|
201
|
+
"status_values" => %w[planned generated candidate approved completed passed pending_rerun stale failed],
|
|
158
202
|
"artifacts" => artifacts
|
|
159
203
|
}
|
|
160
204
|
|
|
@@ -9,6 +9,7 @@ require_relative "control_io"
|
|
|
9
9
|
require_relative "phase_ownership"
|
|
10
10
|
require_relative "requirement_graph"
|
|
11
11
|
require_relative "architecture_feasibility"
|
|
12
|
+
require_relative "architecture_brief"
|
|
12
13
|
require_relative "checker_registry"
|
|
13
14
|
require_relative "requirement_coverage"
|
|
14
15
|
require_relative "workflow_gate_contract"
|
|
@@ -740,7 +741,15 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
740
741
|
|
|
741
742
|
graph, = RequirementGraph.load_graph(root, feature)
|
|
742
743
|
if brief["graph_revision"] != graph["revision"]
|
|
743
|
-
|
|
744
|
+
brief_markdown_path = File.join(root, ".planning", "features", feature, "architecture-brief.md")
|
|
745
|
+
current_markdown = ArchitectureBrief.build(root, feature)[3]
|
|
746
|
+
normalize_revision = lambda do |markdown|
|
|
747
|
+
markdown.to_s.gsub(/需求图谱:schema v(\d+) \/ revision \d+/, '需求图谱:schema v\1 / revision <current>')
|
|
748
|
+
end
|
|
749
|
+
semantically_current = File.file?(brief_markdown_path) && normalize_revision.call(File.read(brief_markdown_path)) == normalize_revision.call(current_markdown)
|
|
750
|
+
unless semantically_current
|
|
751
|
+
issues << issue("error", "architecture_brief_graph_stale", "Architecture brief inputs changed after it was derived", step: step["id"], brief_revision: brief["graph_revision"], graph_revision: graph["revision"])
|
|
752
|
+
end
|
|
744
753
|
end
|
|
745
754
|
current_context = ArchitectureFeasibility.safe_static_context(root)
|
|
746
755
|
current_context_revision = current_context["available"] ? current_context["snapshot_revision"] : nil
|