@zalom/plastic 2.0.0-alpha.9 → 2.0.2
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/PLASTIC.md +13 -136
- package/README.md +357 -133
- package/agents/plastic-enforcer.md +21 -16
- package/agents/plastic-executor.md +15 -4
- package/agents/plastic-node-research.md +30 -0
- package/agents/plastic-node-verify.md +28 -0
- package/agents/plastic-node-work.md +33 -0
- package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
- package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
- package/assets/plastic-logo.svg +1 -0
- package/bin/crap +4 -0
- package/bin/lib/context_budget.rb +43 -6
- package/bin/lib/skill_census.rb +839 -0
- package/bin/plastic +8 -0
- package/bin/plastic-skill-census +114 -0
- package/bin/test +24 -4
- package/bin/verify-change +345 -0
- package/config_asks.yml +4 -4
- package/deprecations.yml +1 -1
- package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
- package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
- package/docs/help/completion-and-done.md +53 -0
- package/docs/help/human-report-contract.md +152 -0
- package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
- package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
- package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
- package/{skills/conventions/references → docs/help}/roadmaps.md +5 -4
- package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
- package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
- package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +55 -2
- package/hooks/session-start +5 -1
- package/hooks/statusline +32 -27
- package/hooks/stop +5 -0
- package/package.json +5 -3
- package/scripts/append-ledger +2 -1
- package/scripts/dashboard.rb +267 -16
- package/scripts/day-summary +2 -1
- package/scripts/doctor.rb +680 -39
- package/scripts/end-intent +378 -56
- package/scripts/exec-worktree +5 -5
- package/scripts/file-session-intent +2 -1
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +24 -123
- package/scripts/hook-close +2 -1
- package/scripts/hook-message-display +7 -0
- package/scripts/hook-record +24 -16
- package/scripts/hook-savepoint +27 -3
- package/scripts/hook-session-start +359 -321
- package/scripts/hook-stop +58 -0
- package/scripts/index-projection +74 -0
- package/scripts/insight-append +17 -4
- package/scripts/install.rb +9 -7
- package/scripts/lib/action_graph_shim.rb +279 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +63 -25
- package/scripts/lib/arm.rb +74 -91
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/backup.rb +65 -0
- package/scripts/lib/cli/command.rb +92 -0
- package/scripts/lib/cli/commands/auto.rb +18 -0
- package/scripts/lib/cli/commands/auto_brief.rb +44 -0
- package/scripts/lib/cli/commands/auto_lock.rb +111 -0
- package/scripts/lib/cli/commands/auto_report.rb +50 -0
- package/scripts/lib/cli/commands/auto_take.rb +68 -0
- package/scripts/lib/cli/commands/backup.rb +43 -0
- package/scripts/lib/cli/commands/checkout.rb +25 -0
- package/scripts/lib/cli/commands/continue.rb +66 -0
- package/scripts/lib/cli/commands/doctor.rb +81 -0
- package/scripts/lib/cli/commands/feedback.rb +40 -0
- package/scripts/lib/cli/commands/help.rb +69 -0
- package/scripts/lib/cli/commands/hook.rb +32 -0
- package/scripts/lib/cli/commands/index.rb +23 -0
- package/scripts/lib/cli/commands/install.rb +21 -0
- package/scripts/lib/cli/commands/installer_verb.rb +37 -0
- package/scripts/lib/cli/commands/intent.rb +19 -0
- package/scripts/lib/cli/commands/intent_answer.rb +37 -0
- package/scripts/lib/cli/commands/intent_command.rb +58 -0
- package/scripts/lib/cli/commands/intent_end.rb +75 -0
- package/scripts/lib/cli/commands/intent_new.rb +62 -0
- package/scripts/lib/cli/commands/intent_note.rb +43 -0
- package/scripts/lib/cli/commands/intent_rule.rb +36 -0
- package/scripts/lib/cli/commands/intent_show.rb +32 -0
- package/scripts/lib/cli/commands/intent_spec.rb +44 -0
- package/scripts/lib/cli/commands/intent_step.rb +79 -0
- package/scripts/lib/cli/commands/intent_verify.rb +26 -0
- package/scripts/lib/cli/commands/migrate.rb +16 -0
- package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
- package/scripts/lib/cli/commands/next.rb +54 -0
- package/scripts/lib/cli/commands/project.rb +19 -0
- package/scripts/lib/cli/commands/project_links.rb +46 -0
- package/scripts/lib/cli/commands/project_list.rb +20 -0
- package/scripts/lib/cli/commands/project_new.rb +95 -0
- package/scripts/lib/cli/commands/query.rb +31 -0
- package/scripts/lib/cli/commands/render.rb +35 -0
- package/scripts/lib/cli/commands/roadmap.rb +19 -0
- package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
- package/scripts/lib/cli/commands/roadmap_next.rb +77 -0
- package/scripts/lib/cli/commands/roadmap_show.rb +65 -0
- package/scripts/lib/cli/commands/rollback.rb +20 -0
- package/scripts/lib/cli/commands/search.rb +60 -0
- package/scripts/lib/cli/commands/session.rb +18 -0
- package/scripts/lib/cli/commands/session_commit.rb +42 -0
- package/scripts/lib/cli/commands/session_handoff.rb +34 -0
- package/scripts/lib/cli/commands/session_summary.rb +35 -0
- package/scripts/lib/cli/commands/status.rb +68 -0
- package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
- package/scripts/lib/cli/commands/sync.rb +55 -0
- package/scripts/lib/cli/commands/uninstall.rb +20 -0
- package/scripts/lib/cli/commands/update.rb +20 -0
- package/scripts/lib/cli/commands/version.rb +53 -0
- package/scripts/lib/cli/frontier.rb +86 -0
- package/scripts/lib/cli/intent_progress.rb +36 -0
- package/scripts/lib/cli/legacy.rb +80 -0
- package/scripts/lib/cli/output.rb +137 -0
- package/scripts/lib/cli/scope.rb +134 -0
- package/scripts/lib/cli/table.rb +65 -0
- package/scripts/lib/cli.rb +94 -0
- package/scripts/lib/codex_adapter.rb +198 -0
- package/scripts/lib/compact_instructions.rb +13 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/data_boundary.rb +132 -0
- package/scripts/lib/day_summary.rb +23 -14
- package/scripts/lib/doctor_core.rb +114 -38
- package/scripts/lib/doctor_session_ledger.rb +5 -53
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/feedback_report.rb +1 -1
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +413 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +27 -3
- package/scripts/lib/hook_replay.rb +235 -0
- package/scripts/lib/index_entry.rb +62 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +543 -71
- package/scripts/lib/intent_screen.rb +4 -4
- package/scripts/lib/intent_screen_ansi.rb +73 -12
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +10 -11
- package/scripts/lib/message_display.rb +350 -54
- package/scripts/lib/meter_watch.rb +185 -0
- package/scripts/lib/node_file.rb +234 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_input.rb +921 -0
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +386 -0
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/node_return.rb +204 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/preflight.rb +4 -6
- package/scripts/lib/project_config.rb +46 -0
- package/scripts/lib/project_validator.rb +3 -2
- package/scripts/lib/qmd_sync.rb +8 -7
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/reference_archive.rb +45 -0
- package/scripts/lib/release_guard.rb +18 -0
- package/scripts/lib/report_screen.rb +1371 -47
- package/scripts/lib/rlm/corpus.rb +13 -0
- package/scripts/lib/rlm/probe.rb +29 -0
- package/scripts/lib/rlm/query.rb +22 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +170 -12
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +64 -14
- package/scripts/lib/runner_absorb.rb +703 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +526 -0
- package/scripts/lib/runner_policy.rb +191 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +141 -19
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/screen_paint.rb +365 -28
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/search_index.rb +55 -0
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +67 -19
- package/scripts/lib/session_ledger.rb +48 -4
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/sqlite.rb +22 -0
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/store_discovery.rb +7 -6
- package/scripts/lib/store_layout.rb +54 -0
- package/scripts/lib/store_provisioning.rb +2 -1
- package/scripts/lib/store_sync.rb +85 -0
- package/scripts/lib/stores_move.rb +93 -0
- package/scripts/lib/untouched_scaffold.rb +51 -0
- package/scripts/lib/verify_intent.rb +36 -8
- package/scripts/lib/version_number.rb +48 -0
- package/scripts/lib/work_graph.rb +59 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/lib/worktree.rb +27 -32
- package/scripts/lib/worktree_sweep.rb +6 -5
- package/scripts/link-suggest +2 -1
- package/scripts/meter-watch +57 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +4 -13
- package/scripts/node-input +92 -0
- package/scripts/node-run +225 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +65 -60
- package/scripts/project-links +6 -21
- package/scripts/promote-session-item +3 -2
- package/scripts/read-config +53 -9
- package/scripts/ready-set +126 -0
- package/scripts/release-check +123 -0
- package/scripts/report-screen +177 -16
- package/scripts/roadmap-graph +125 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/rollback.rb +5 -1
- package/scripts/runner +581 -0
- package/scripts/savepoint-note +11 -9
- package/scripts/session-commit +2 -1
- package/scripts/session-usage +56 -0
- package/scripts/skill-lint +115 -6
- package/scripts/spawn-preamble +2 -2
- package/scripts/update.rb +31 -4
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +3 -2
- package/scripts/write-handoff +2 -1
- package/templates/agents.md +7 -7
- package/templates/config.yml +16 -9
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/index.md +1 -1
- package/templates/intent-screen.md +1 -1
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +13 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +8 -3
- package/templates/project.yml +1 -1
- package/templates/render.css +10 -0
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +1 -1
- package/templates/roadmap.md +13 -0
- package/bin/plastic.js +0 -70
- package/scripts/lib/bridge.rb +0 -116
- package/skills/agent-advisor/SKILL.md +0 -92
- package/skills/auto/SKILL.md +0 -296
- package/skills/auto/evals/evals.json +0 -255
- package/skills/auto/references/end-tail.md +0 -66
- package/skills/auto/references/human-report-contract.md +0 -78
- package/skills/conventions/SKILL.md +0 -29
- package/skills/conventions/references/completion-and-done.md +0 -43
- package/skills/dashboard/SKILL.md +0 -169
- package/skills/dashboard/evals/evals.json +0 -38
- package/skills/dashboard/references/classification.md +0 -22
- package/skills/dashboard/templates/dashboard-global.md +0 -20
- package/skills/dashboard/templates/dashboard-project.md +0 -19
- package/skills/direct/SKILL.md +0 -66
- package/skills/direct/references/request-signals.md +0 -59
- package/skills/doctor/SKILL.md +0 -299
- package/skills/doctor/report.md +0 -102
- package/skills/feedback/SKILL.md +0 -98
- package/skills/feedback/references/transport-and-privacy.md +0 -65
- package/skills/feedback/report.md +0 -36
- package/skills/install/SKILL.md +0 -217
- package/skills/intent-continuing/SKILL.md +0 -154
- package/skills/intent-continuing/references/board-fill.md +0 -43
- package/skills/intent-continuing/references/boarding-matrix.md +0 -34
- package/skills/intent-continuing/references/context-management.md +0 -28
- package/skills/intent-continuing/references/liveness-ranking.md +0 -57
- package/skills/intent-creating/SKILL.md +0 -164
- package/skills/intent-creating/evals/evals.json +0 -72
- package/skills/intent-creating/references/lifecycle.md +0 -81
- package/skills/intent-creating/references/wikilinks.md +0 -8
- package/skills/intent-ending/SKILL.md +0 -176
- package/skills/intent-ending/evals/evals.json +0 -74
- package/skills/intent-executing/SKILL.md +0 -170
- package/skills/intent-executing/evals/evals.json +0 -66
- package/skills/intent-executing/implementer-prompt.md +0 -42
- package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
- package/skills/intent-speccing/SKILL.md +0 -130
- package/skills/intent-speccing/evals/evals.json +0 -126
- package/skills/intent-speccing/references/design-principles.md +0 -44
- package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
- package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
- package/skills/project-creating/SKILL.md +0 -162
- package/skills/project-creating/references/hubs-projects.md +0 -55
- package/skills/project-creating/references/project-scaffolding.md +0 -97
- package/skills/releasing/SKILL.md +0 -337
- package/skills/releasing/references/deprecations.md +0 -60
- package/skills/releasing/references/promotion-and-tagging.md +0 -66
- package/skills/releasing/references/release-lines.md +0 -105
- package/skills/roadmap/SKILL.md +0 -64
- package/skills/roadmap/references/file-format.md +0 -124
- package/skills/roadmap/references/operations.md +0 -112
- package/skills/rollback/SKILL.md +0 -91
- package/skills/tutorial/SKILL.md +0 -65
- package/skills/tutorial/evals/evals.json +0 -186
- package/skills/uninstall/SKILL.md +0 -75
- package/skills/update/SKILL.md +0 -126
- /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
- /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
- /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
- /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "corpus"
|
|
4
|
+
|
|
5
|
+
module RLM
|
|
6
|
+
class Probe
|
|
7
|
+
STOP_WORDS = %w[a an and are as at be by do does for from how in is it of on or that the this to was what when where which who why with].freeze
|
|
8
|
+
DEFAULT_LIMIT = 10
|
|
9
|
+
|
|
10
|
+
def initialize(corpus, limit: DEFAULT_LIMIT)
|
|
11
|
+
@corpus = corpus
|
|
12
|
+
@limit = limit
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(question)
|
|
16
|
+
hits = terms(question).flat_map { |term| @corpus.call([term], @limit) }
|
|
17
|
+
hits.group_by { |row| row["id"] }.values
|
|
18
|
+
.map { |rows| rows.first.merge("score" => rows.size) }
|
|
19
|
+
.sort_by.with_index { |row, index| [-row["score"], index] }
|
|
20
|
+
.first(@limit)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def terms(question)
|
|
26
|
+
question.downcase.scan(/[[:alnum:]]+/).uniq - STOP_WORDS
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "probe"
|
|
4
|
+
|
|
5
|
+
module RLM
|
|
6
|
+
class Query
|
|
7
|
+
DEFAULT_LIMIT = 3
|
|
8
|
+
|
|
9
|
+
def initialize(probe, judge: nil, limit: DEFAULT_LIMIT)
|
|
10
|
+
@probe = probe
|
|
11
|
+
@judge = judge
|
|
12
|
+
@limit = limit
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(question)
|
|
16
|
+
rows = @probe.call(question)
|
|
17
|
+
return rows unless @judge && rows.size > @limit
|
|
18
|
+
|
|
19
|
+
@judge.call(question, rows)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_file"
|
|
5
|
+
require_relative "graph_edges"
|
|
6
|
+
require_relative "ready_set"
|
|
7
|
+
require_relative "roadmap_savepoint"
|
|
8
|
+
|
|
9
|
+
# RoadmapGraph (intent 337, n1): reads one roadmap file into the same shape
|
|
10
|
+
# the node scope already uses - entries with their INDEX-reconciled status,
|
|
11
|
+
# the edge map from "## Graph", the cycle path when one exists, the
|
|
12
|
+
# topological batches, the critical paths, and the ready set. Edges are
|
|
13
|
+
# parsed by GraphEdges; batches, critical paths, and downstream hops come
|
|
14
|
+
# from ReadySet (327 D1, C1: the one parser, the one topological sort - a
|
|
15
|
+
# local copy of either here is exactly the drift 327 forbids). This module
|
|
16
|
+
# writes no file and reads no clock or environment variable (row 1.14);
|
|
17
|
+
# every fact is either a value passed in or read once from the two paths
|
|
18
|
+
# `analyze` is given.
|
|
19
|
+
module RoadmapGraph
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+(.*?)[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
|
|
23
|
+
WAVE_HEADING = /\A###\s+(.+?)\s*\z/.freeze
|
|
24
|
+
|
|
25
|
+
INDEX_TAGS = { "Completed" => "delivered", "Abandoned" => "abandoned", "Active" => :active, "Future" => "queued" }.freeze
|
|
26
|
+
|
|
27
|
+
# analyze(roadmap_path, index_path:) -> a Result hash. Never raises across
|
|
28
|
+
# the boundary: a missing ## Graph section, a missing grouping heading, a
|
|
29
|
+
# cyclic graph, or an invalid byte in either file all become a named
|
|
30
|
+
# `reason` or an empty result, never an exception (rows 1.8, 1.9, 1.13).
|
|
31
|
+
def analyze(roadmap_path, index_path: nil)
|
|
32
|
+
text = read_utf8(roadmap_path)
|
|
33
|
+
|
|
34
|
+
graph_section = GraphFile.section_body(text, "## Graph")
|
|
35
|
+
unless graph_section
|
|
36
|
+
return result(has_graph: false, reason: "no ## Graph section in #{roadmap_path}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
parsed_edges = GraphEdges.parse(GraphFile.strip_fenced_blocks(graph_section))
|
|
40
|
+
|
|
41
|
+
entries_by_id, order = begin
|
|
42
|
+
parse_entries(text, roadmap_path)
|
|
43
|
+
rescue RoadmapSavepoint::MissingGroupingHeading => e
|
|
44
|
+
return result(has_graph: true, reason: e.message)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
index_map = load_index(index_path)
|
|
48
|
+
entries_by_id.each_value { |e| e[:status] = reconcile_status(index_map[e[:id]], e[:raw_status]) }
|
|
49
|
+
|
|
50
|
+
declared_and_targeted = parsed_edges[:nodes]
|
|
51
|
+
entry_ids = order.dup
|
|
52
|
+
|
|
53
|
+
# 1.6: an entry the graph never named (not declared, not targeted) is
|
|
54
|
+
# included as a root, so a partly migrated roadmap never silently
|
|
55
|
+
# drops real work from the batches.
|
|
56
|
+
edges = parsed_edges[:edges].dup
|
|
57
|
+
entry_ids.each do |id|
|
|
58
|
+
next if declared_and_targeted.include?(id)
|
|
59
|
+
|
|
60
|
+
edges[id] = []
|
|
61
|
+
entries_by_id[id][:in_graph] = false
|
|
62
|
+
end
|
|
63
|
+
entry_ids.each do |id|
|
|
64
|
+
entries_by_id[id][:in_graph] = true if declared_and_targeted.include?(id) && entries_by_id[id][:in_graph].nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 1.7: a graph id with no batch entry is named in `dangling`, never
|
|
68
|
+
# invented as a fake entry with no title.
|
|
69
|
+
dangling = declared_and_targeted.reject { |id| entries_by_id.key?(id) }
|
|
70
|
+
|
|
71
|
+
cyc = GraphEdges.cycle(edges)
|
|
72
|
+
if cyc
|
|
73
|
+
return result(has_graph: true, entries: entries_by_id, edges: edges, dangling: dangling,
|
|
74
|
+
cycle: cyc, errors: parsed_edges[:errors])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
batch_result = ReadySet.batches(edges)
|
|
78
|
+
batches = batch_result[:ok] ? order_batches(batch_result[:batches], order) : []
|
|
79
|
+
path_result = ReadySet.critical_paths(edges)
|
|
80
|
+
|
|
81
|
+
status_map = entries_by_id.each_with_object({}) { |(id, e), h| h[id] = e[:status] }
|
|
82
|
+
dead_ends = entry_ids.select do |id|
|
|
83
|
+
entries_by_id[id][:status] != "delivered" && ReadySet.dead_end?(id, edges, status_map)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
ready = entry_ids.select do |id|
|
|
87
|
+
entries_by_id[id][:status] == "queued" &&
|
|
88
|
+
(edges[id] || []).all? { |t| status_map[t] == "delivered" }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
result(
|
|
92
|
+
has_graph: true,
|
|
93
|
+
entries: entries_by_id,
|
|
94
|
+
edges: edges,
|
|
95
|
+
dangling: dangling,
|
|
96
|
+
cycle: nil,
|
|
97
|
+
batches: batches,
|
|
98
|
+
batches_error: batch_result[:ok] ? nil : batch_result[:error],
|
|
99
|
+
critical_paths: path_result[:ok] ? path_result : nil,
|
|
100
|
+
dead_ends: dead_ends,
|
|
101
|
+
ready: ready,
|
|
102
|
+
errors: parsed_edges[:errors]
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- shared building blocks other callers (RoadmapQueue, n9) reuse --------
|
|
107
|
+
|
|
108
|
+
# The one place that knows how a roadmap's own "## Graph" section is read:
|
|
109
|
+
# an exact heading (GraphFile), fenced examples stripped, and an edgeless
|
|
110
|
+
# section treated the same as no section at all. Returns GraphEdges.parse's
|
|
111
|
+
# own Result hash, or nil.
|
|
112
|
+
def parse_graph_section(text)
|
|
113
|
+
section = GraphFile.section_body(text, "## Graph")
|
|
114
|
+
return nil if section.nil?
|
|
115
|
+
|
|
116
|
+
parsed = GraphEdges.parse(GraphFile.strip_fenced_blocks(section))
|
|
117
|
+
return nil if parsed[:nodes].empty?
|
|
118
|
+
|
|
119
|
+
parsed
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ReadySet.batches's own topological layers (the one sort, D1), reordered
|
|
123
|
+
# WITHIN each layer to match `order` - typically the roadmap file's own
|
|
124
|
+
# entry-encounter order (row 1.16/9.8). A computed batch names which layer
|
|
125
|
+
# an id is in; it must never silently become a ranking decision by
|
|
126
|
+
# defaulting to a lexical sort of ids that happen to share a layer.
|
|
127
|
+
def order_batches(batches, order)
|
|
128
|
+
rank = {}
|
|
129
|
+
order.each_with_index { |id, i| rank[id] = i }
|
|
130
|
+
batches.map { |layer| layer.sort_by { |id| [rank[id] || order.length, id] } }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# --- entries -----------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def parse_entries(text, roadmap_path)
|
|
136
|
+
body = RoadmapSavepoint.grouping_section_body(text, path: roadmap_path)
|
|
137
|
+
entries = {}
|
|
138
|
+
order = []
|
|
139
|
+
body.each_line do |line|
|
|
140
|
+
stripped = line.chomp.strip
|
|
141
|
+
m = stripped.match(ENTRY)
|
|
142
|
+
next unless m
|
|
143
|
+
|
|
144
|
+
id = m[2]
|
|
145
|
+
next if entries.key?(id)
|
|
146
|
+
|
|
147
|
+
entries[id] = { id: id, title: m[3].strip, raw_status: m[4].downcase, in_graph: nil }
|
|
148
|
+
order << id
|
|
149
|
+
end
|
|
150
|
+
[entries, order]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def reconcile_status(tag, raw_status)
|
|
154
|
+
case tag
|
|
155
|
+
when "delivered" then "delivered"
|
|
156
|
+
when "abandoned" then "abandoned"
|
|
157
|
+
when "queued" then "queued"
|
|
158
|
+
when :active then raw_status == "delivered" ? "delivering" : raw_status
|
|
159
|
+
else raw_status
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def load_index(index_path)
|
|
164
|
+
map = {}
|
|
165
|
+
return map unless index_path && File.exist?(index_path)
|
|
166
|
+
|
|
167
|
+
text = read_utf8(index_path)
|
|
168
|
+
INDEX_TAGS.each do |heading, tag|
|
|
169
|
+
section_body(text, heading).each_line do |line|
|
|
170
|
+
stripped = line.strip
|
|
171
|
+
next unless stripped.start_with?("- [")
|
|
172
|
+
|
|
173
|
+
m = stripped.match(/\A-\s*\[(\S+)\s/)
|
|
174
|
+
map[m[1]] = tag if m
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
map
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def section_body(text, heading)
|
|
181
|
+
m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
|
|
182
|
+
m ? m[1] : ""
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# 1.13: an invalid byte in either file is scrubbed, never raised across
|
|
186
|
+
# the boundary.
|
|
187
|
+
def read_utf8(path)
|
|
188
|
+
text = File.read(path)
|
|
189
|
+
text.force_encoding(Encoding::UTF_8)
|
|
190
|
+
text.valid_encoding? ? text : text.scrub("")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def result(has_graph:, reason: nil, entries: {}, edges: {}, dangling: [], cycle: nil,
|
|
194
|
+
batches: [], batches_error: nil, critical_paths: nil, dead_ends: [], ready: [], errors: [])
|
|
195
|
+
{
|
|
196
|
+
has_graph: has_graph,
|
|
197
|
+
reason: reason,
|
|
198
|
+
entries: entries,
|
|
199
|
+
edges: edges,
|
|
200
|
+
dangling: dangling,
|
|
201
|
+
cycle: cycle,
|
|
202
|
+
batches: batches,
|
|
203
|
+
batches_error: batches_error,
|
|
204
|
+
critical_paths: critical_paths,
|
|
205
|
+
dead_ends: dead_ends,
|
|
206
|
+
ready: ready,
|
|
207
|
+
errors: errors,
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "roadmap_graph"
|
|
5
|
+
require_relative "roadmap_render"
|
|
6
|
+
require_relative "roadmap_savepoint"
|
|
7
|
+
require_relative "graph_edges"
|
|
8
|
+
require_relative "graph_file"
|
|
9
|
+
require_relative "atomic_write"
|
|
10
|
+
|
|
11
|
+
# RoadmapMigration (intent 337, n4): reads a graphless roadmap's existing
|
|
12
|
+
# batch order and returns the conservative edge set that preserves it -
|
|
13
|
+
# every entry of batch N needs every entry of batch N-1, batch 1 needs
|
|
14
|
+
# nothing. Never overwrites a roadmap that already carries a "## Graph" (or
|
|
15
|
+
# graph-like, row 4.15) heading: the owner's hand-edited edges are the only
|
|
16
|
+
# place a cross-intent edge lives, and a derived guess must never replace
|
|
17
|
+
# them. Block splitting and entry-id extraction are the same surgical
|
|
18
|
+
# parser RoadmapRender uses (n3) - never a second one.
|
|
19
|
+
module RoadmapMigration
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
GRAPH_HEADING_RE = /\A##\s+Graph\b/.freeze
|
|
23
|
+
|
|
24
|
+
# {ok:, skipped:, edges:, reason:}. Computes only; writes nothing.
|
|
25
|
+
def derive(path, index_path: nil)
|
|
26
|
+
text = File.read(path)
|
|
27
|
+
|
|
28
|
+
if text.each_line.any? { |l| l.strip.match?(GRAPH_HEADING_RE) }
|
|
29
|
+
return { ok: false, skipped: true, edges: {}, reason: "already carries a ## Graph (or graph-like) heading, left untouched" }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
body = RoadmapSavepoint.grouping_section_body(text, path: path)
|
|
34
|
+
rescue RoadmapSavepoint::MissingGroupingHeading => e
|
|
35
|
+
return { ok: false, skipped: false, edges: {}, reason: e.message }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
_preamble, blocks = RoadmapRender.split_into_blocks(body)
|
|
39
|
+
batch_ids = blocks.map { |b| b[:lines].filter_map { |l| RoadmapRender.entry_id(l) } }
|
|
40
|
+
|
|
41
|
+
entries_by_id = entry_statuses(text, path, index_path)
|
|
42
|
+
|
|
43
|
+
edges = {}
|
|
44
|
+
batch_ids.each_with_index do |ids, i|
|
|
45
|
+
prev_ids = i.zero? ? [] : batch_ids[i - 1].reject { |id| abandoned?(entries_by_id, id) }
|
|
46
|
+
# An id repeated across two batches (row 4.16) must never need itself:
|
|
47
|
+
# exclude it from its own prev_ids before assigning.
|
|
48
|
+
ids.each { |id| edges[id] = prev_ids.reject { |p| p == id } }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
cyc = GraphEdges.cycle(edges)
|
|
52
|
+
return { ok: false, skipped: false, edges: {}, reason: "derived graph is cyclic: #{cyc.join(' > ')}" } if cyc
|
|
53
|
+
|
|
54
|
+
{ ok: true, skipped: false, edges: edges, reason: nil }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# derive + render "## Graph" through AtomicWrite. A skip (row 4.3) or a
|
|
58
|
+
# refusal (cyclic, row 4.5) writes nothing and reports why.
|
|
59
|
+
def write(path, renamer: File.method(:rename), dry_run: false, index_path: nil)
|
|
60
|
+
result = derive(path, index_path: index_path)
|
|
61
|
+
return { ok: result[:ok], written: false, content: nil, skipped: result[:skipped], reason: result[:reason] } unless result[:ok]
|
|
62
|
+
|
|
63
|
+
graph_text = render_edges(result[:edges])
|
|
64
|
+
original = File.read(path)
|
|
65
|
+
content = GraphFile.replace_or_append_section(original, "## Graph", graph_text)
|
|
66
|
+
|
|
67
|
+
if dry_run
|
|
68
|
+
{ ok: true, written: false, content: content, skipped: false, reason: nil }
|
|
69
|
+
else
|
|
70
|
+
AtomicWrite.write(path, content, renamer: renamer)
|
|
71
|
+
{ ok: true, written: true, content: content, skipped: false, reason: nil }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def render_edges(edges)
|
|
76
|
+
lines = edges.map do |id, needs|
|
|
77
|
+
"- #{id} needs #{needs.empty? ? 'nothing' : needs.join(' ')}\n"
|
|
78
|
+
end
|
|
79
|
+
lines.join
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def entry_statuses(text, path, index_path)
|
|
83
|
+
entries_by_id, = RoadmapGraph.parse_entries(text, path)
|
|
84
|
+
resolved_index = index_path || RoadmapRender.default_index_path(path)
|
|
85
|
+
index_map = RoadmapGraph.load_index(resolved_index)
|
|
86
|
+
entries_by_id.each_value { |e| e[:status] = RoadmapGraph.reconcile_status(index_map[e[:id]], e[:raw_status]) }
|
|
87
|
+
entries_by_id
|
|
88
|
+
rescue RoadmapSavepoint::MissingGroupingHeading
|
|
89
|
+
{}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def abandoned?(entries_by_id, id)
|
|
93
|
+
entries_by_id[id] && entries_by_id[id][:status] == "abandoned"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
require "time"
|
|
5
5
|
require "json"
|
|
6
6
|
require_relative "roadmap_savepoint"
|
|
7
|
+
require_relative "graph_file"
|
|
8
|
+
require_relative "graph_edges"
|
|
9
|
+
require_relative "ready_set"
|
|
10
|
+
require_relative "roadmap_graph"
|
|
7
11
|
|
|
8
12
|
# FileOrderRanker - the default value-ordering strategy: today's roadmap file order,
|
|
9
13
|
# unchanged. This is the intent-173 ranking-swap seam (sibling to the 147 DB-swap seam): a
|
|
@@ -34,7 +38,9 @@ class RoadmapQueue
|
|
|
34
38
|
# Entry line parser, anchored on the status vocabulary rather than end of line, so a trailing
|
|
35
39
|
# parenthetical ("delivering (owner ruling...)") does not defeat the match. Accepts the em
|
|
36
40
|
# dash or a hyphen as the separator; roadmap .md files are store-internal and use the em dash.
|
|
37
|
-
|
|
41
|
+
# Intent 331c: group 3 captures the entry's own title text (between the id and the status
|
|
42
|
+
# separator), so a screen reader has it without a second parser; group 4 (was 3) is the status.
|
|
43
|
+
ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+(.*?)[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
|
|
38
44
|
|
|
39
45
|
WAVE_HEADING = /\A###\s+(.+?)\s*\z/.freeze
|
|
40
46
|
|
|
@@ -57,6 +63,21 @@ class RoadmapQueue
|
|
|
57
63
|
analyze(mode: "which")
|
|
58
64
|
end
|
|
59
65
|
|
|
66
|
+
# Intent 331c (D6/R1): the one public reader for ONE roadmap's parsed, INDEX-reconciled shape,
|
|
67
|
+
# so a screen never carries a second parser that can drift from this class's own grammar. Same
|
|
68
|
+
# reconciliation (`reconcile`) and the same frontier selection (`frontier_for`, R17) `queue`/
|
|
69
|
+
# `which` use for the whole tier, scoped to the single file at `path`.
|
|
70
|
+
def roadmap(path)
|
|
71
|
+
parsed = reconcile([parse_roadmap(path)]).first
|
|
72
|
+
{
|
|
73
|
+
slug: parsed[:slug],
|
|
74
|
+
path: parsed[:path],
|
|
75
|
+
grouping: RoadmapSavepoint.grouping_heading(File.read(path)),
|
|
76
|
+
batches: parsed[:waves],
|
|
77
|
+
frontier: frontier_for(parsed),
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
60
81
|
private
|
|
61
82
|
|
|
62
83
|
def analyze(mode:)
|
|
@@ -81,6 +102,15 @@ class RoadmapQueue
|
|
|
81
102
|
|
|
82
103
|
winner = ranked.first
|
|
83
104
|
is_tie = tied.length > 1
|
|
105
|
+
|
|
106
|
+
cyc = winner[:graph_edges] && GraphEdges.cycle(winner[:graph_edges][:edges])
|
|
107
|
+
if cyc
|
|
108
|
+
return payload(mode: mode, state: "error", roadmap: winner[:slug],
|
|
109
|
+
frontier_wave: "cyclic roadmap graph, cannot compute a frontier: #{cyc.join(' > ')}",
|
|
110
|
+
dispatchable: [], in_flight: [], blocked: blocked_for(winner),
|
|
111
|
+
tie: is_tie, tie_candidates: [])
|
|
112
|
+
end
|
|
113
|
+
|
|
84
114
|
frontier = frontier_for(winner)
|
|
85
115
|
|
|
86
116
|
state =
|
|
@@ -112,7 +142,18 @@ class RoadmapQueue
|
|
|
112
142
|
|
|
113
143
|
def parse_roadmap(path)
|
|
114
144
|
text = File.read(path)
|
|
115
|
-
{
|
|
145
|
+
{
|
|
146
|
+
slug: File.basename(path, ".md"), path: path,
|
|
147
|
+
waves: parse_waves(RoadmapSavepoint.grouping_section_body(text, path: path)),
|
|
148
|
+
# D15: an exact "## Graph" heading line only, never a prefix - a live
|
|
149
|
+
# roadmap can carry "## Graph (2026-09-01, superseded by ...)". A
|
|
150
|
+
# section that yields no real edge lines is treated the same as no
|
|
151
|
+
# section at all: the fallback to wave order, both silent (D15).
|
|
152
|
+
# Fence-aware (D18). Intent 337 n9: reads through the shared model
|
|
153
|
+
# (RoadmapGraph.parse_graph_section) rather than a second local
|
|
154
|
+
# parser, so this heading/fence/emptiness rule is made in one place.
|
|
155
|
+
graph_edges: RoadmapGraph.parse_graph_section(text),
|
|
156
|
+
}
|
|
116
157
|
end
|
|
117
158
|
|
|
118
159
|
def parse_waves(waves_body)
|
|
@@ -124,7 +165,7 @@ class RoadmapQueue
|
|
|
124
165
|
current = { heading: m[1], entries: [] }
|
|
125
166
|
waves << current
|
|
126
167
|
elsif current && (em = stripped.match(ENTRY))
|
|
127
|
-
current[:entries] << { id: em[2],
|
|
168
|
+
current[:entries] << { id: em[2], text: em[3].strip, raw_status: em[4].downcase }
|
|
128
169
|
end
|
|
129
170
|
end
|
|
130
171
|
waves
|
|
@@ -150,7 +191,7 @@ class RoadmapQueue
|
|
|
150
191
|
case index_status_map[id]
|
|
151
192
|
when "delivered" then "delivered"
|
|
152
193
|
when "abandoned" then "abandoned"
|
|
153
|
-
when "queued" then "queued"
|
|
194
|
+
when "queued" then raw_status == "blocked" ? "blocked" : "queued"
|
|
154
195
|
when :active then raw_status == "delivered" ? "delivering" : raw_status
|
|
155
196
|
else raw_status
|
|
156
197
|
end
|
|
@@ -183,18 +224,26 @@ class RoadmapQueue
|
|
|
183
224
|
|
|
184
225
|
# --- liveness ranking (ports plastic-intent-continuing's read-time algorithm) -
|
|
185
226
|
|
|
227
|
+
# A roadmap whose graph has a cycle cannot compute a frontier, so it ranks
|
|
228
|
+
# after every healthy one (acceptance N8). It still wins when nothing else
|
|
229
|
+
# is left, and the payload then reports the cycle as an error.
|
|
186
230
|
def rank_candidates(parsed_list)
|
|
187
231
|
parsed_list.map do |c|
|
|
188
232
|
entries = c[:waves].flat_map { |w| w[:entries] }
|
|
189
233
|
live = entries.any? { |e| %w[delivering blocked].include?(e[:status]) }
|
|
190
|
-
c
|
|
191
|
-
|
|
234
|
+
cyclic = !!(c[:graph_edges] && GraphEdges.cycle(c[:graph_edges][:edges]))
|
|
235
|
+
c.merge(live: live, cyclic: cyclic, last_event: last_event_time(c[:path]))
|
|
236
|
+
end.sort_by { |c| rank_key(c) + [c[:slug]] }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def rank_key(candidate)
|
|
240
|
+
[candidate[:cyclic] ? 1 : 0, candidate[:live] ? 0 : 1, -candidate[:last_event].to_i]
|
|
192
241
|
end
|
|
193
242
|
|
|
194
243
|
def tied_group(ranked)
|
|
195
244
|
return [] if ranked.empty?
|
|
196
|
-
top_key =
|
|
197
|
-
ranked.select { |c|
|
|
245
|
+
top_key = rank_key(ranked.first)
|
|
246
|
+
ranked.select { |c| rank_key(c) == top_key }
|
|
198
247
|
end
|
|
199
248
|
|
|
200
249
|
def last_event_time(path)
|
|
@@ -224,9 +273,17 @@ class RoadmapQueue
|
|
|
224
273
|
Time.utc(y, mo, d, h, mi, 0)
|
|
225
274
|
end
|
|
226
275
|
|
|
227
|
-
# --- frontier + dispatchable selection (D-b)
|
|
276
|
+
# --- frontier + dispatchable selection (D-b, D15) -------------------------------
|
|
228
277
|
|
|
278
|
+
# D15: a candidate with a real ## Graph section dispatches by its edges
|
|
279
|
+
# (a delivered entry counts as done); one without, or whose graph section
|
|
280
|
+
# yields no edges (parse_roadmap_graph already returns nil for that case),
|
|
281
|
+
# keeps the wave-order behavior unchanged.
|
|
229
282
|
def frontier_for(candidate)
|
|
283
|
+
candidate[:graph_edges] ? graph_frontier_for(candidate) : wave_frontier_for(candidate)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def wave_frontier_for(candidate)
|
|
230
287
|
candidate[:waves].each do |wave|
|
|
231
288
|
statuses = wave[:entries].map { |e| e[:status] }
|
|
232
289
|
next unless statuses.any? { |s| %w[queued delivering].include?(s) }
|
|
@@ -250,19 +307,120 @@ class RoadmapQueue
|
|
|
250
307
|
nil
|
|
251
308
|
end
|
|
252
309
|
|
|
310
|
+
# D15's edge-driven frontier: the topological layers of the graph's edges,
|
|
311
|
+
# walked in order; the first layer holding a dispatchable (all needs
|
|
312
|
+
# delivered) or in-flight entry is the frontier. An id the graph names but
|
|
313
|
+
# no batch lists is reported in `blocked` (finding 2, R-2), never a crash
|
|
314
|
+
# and never an invented dispatchable entry with no title. An id a batch
|
|
315
|
+
# lists that the graph does not name is never dropped either: it keeps
|
|
316
|
+
# the pre-change wave behavior (the G4 partial-migration path) by being
|
|
317
|
+
# included as needing nothing (never called on a cyclic graph: #analyze
|
|
318
|
+
# checks that first and reports "error" instead).
|
|
319
|
+
def graph_frontier_for(candidate)
|
|
320
|
+
edges = candidate[:graph_edges][:edges].dup
|
|
321
|
+
id_to_entry = {}
|
|
322
|
+
wave_of_id = {}
|
|
323
|
+
candidate[:waves].each do |wave|
|
|
324
|
+
wave[:entries].each do |e|
|
|
325
|
+
id_to_entry[e[:id]] = e
|
|
326
|
+
wave_of_id[e[:id]] = wave[:heading]
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
(id_to_entry.keys - all_graph_nodes(edges)).each { |id| edges[id] = [] }
|
|
331
|
+
|
|
332
|
+
# Intent 337 n9: the one topological sort (327 D1) is ReadySet.batches;
|
|
333
|
+
# RoadmapGraph.order_batches only reorders WITHIN a layer to match the
|
|
334
|
+
# roadmap file's own entry order (row 9.8), never a second sort.
|
|
335
|
+
batch_result = ReadySet.batches(edges)
|
|
336
|
+
layers = batch_result[:ok] ? RoadmapGraph.order_batches(batch_result[:batches], id_to_entry.keys) : []
|
|
337
|
+
|
|
338
|
+
layers.each do |layer|
|
|
339
|
+
layer_ids = layer.select { |id| id_to_entry.key?(id) }
|
|
340
|
+
next if layer_ids.empty?
|
|
341
|
+
|
|
342
|
+
queued_ready = layer_ids.select do |id|
|
|
343
|
+
entry = id_to_entry[id]
|
|
344
|
+
entry[:status] == "queued" &&
|
|
345
|
+
(edges[id] || []).all? { |t| id_to_entry[t] && id_to_entry[t][:status] == "delivered" }
|
|
346
|
+
end
|
|
347
|
+
delivering_ids = layer_ids.select { |id| id_to_entry[id][:status] == "delivering" }
|
|
348
|
+
next if queued_ready.empty? && delivering_ids.empty?
|
|
349
|
+
|
|
350
|
+
ordered = @ranker.rank(queued_ready.map { |id| id_to_entry[id] })
|
|
351
|
+
dispatchable = ordered.each_with_index.map do |e, i|
|
|
352
|
+
{ "id" => e[:id], "scope" => scope_label, "roadmap" => candidate[:slug],
|
|
353
|
+
"wave" => wave_of_id[e[:id]], "status" => "queued", "rank" => i + 1 }
|
|
354
|
+
end
|
|
355
|
+
in_flight = delivering_ids.map do |id|
|
|
356
|
+
{ "id" => id, "roadmap" => candidate[:slug], "wave" => wave_of_id[id], "status" => "delivering" }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
heading = dispatchable.first ? dispatchable.first["wave"] : in_flight.first["wave"]
|
|
360
|
+
return { heading: heading, dispatchable: dispatchable, in_flight: in_flight }
|
|
361
|
+
end
|
|
362
|
+
nil
|
|
363
|
+
end
|
|
364
|
+
|
|
253
365
|
def blocked_for(candidate)
|
|
254
|
-
candidate[:waves].flat_map do |wave|
|
|
366
|
+
explicit = candidate[:waves].flat_map do |wave|
|
|
255
367
|
wave[:entries].select { |e| e[:status] == "blocked" }.map do |e|
|
|
256
368
|
{ "id" => e[:id], "roadmap" => candidate[:slug], "wave" => wave[:heading], "status" => "blocked" }
|
|
257
369
|
end
|
|
258
370
|
end
|
|
371
|
+
explicit + graph_reporting_blocked(candidate)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# R-2 (finding 2): a graph naming an id no batch lists is reported, not
|
|
375
|
+
# swallowed - both the unnamed id itself, and any batch entry whose own
|
|
376
|
+
# declared need points straight at it, which would otherwise sit queued
|
|
377
|
+
# forever with nothing in the payload explaining why. Keeps the invariant
|
|
378
|
+
# that `state` is never "exhausted" while a queued entry sits unaccounted
|
|
379
|
+
# for: an entry that can never resolve still shows up here.
|
|
380
|
+
def graph_reporting_blocked(candidate)
|
|
381
|
+
return [] unless candidate[:graph_edges]
|
|
382
|
+
|
|
383
|
+
edges = candidate[:graph_edges][:edges]
|
|
384
|
+
id_to_entry = candidate[:waves].flat_map { |w| w[:entries] }.each_with_object({}) { |e, h| h[e[:id]] = e }
|
|
385
|
+
unreported = all_graph_nodes(edges).reject { |id| id_to_entry.key?(id) }
|
|
386
|
+
|
|
387
|
+
reported = unreported.map do |id|
|
|
388
|
+
{ "id" => id, "roadmap" => candidate[:slug], "wave" => nil, "status" => "unreported",
|
|
389
|
+
"reason" => "graph names #{id.inspect}, no batch entry" }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
stuck = []
|
|
393
|
+
id_to_entry.each do |id, entry|
|
|
394
|
+
next unless entry[:status] == "queued"
|
|
395
|
+
|
|
396
|
+
(edges[id] || []).each do |target|
|
|
397
|
+
next unless unreported.include?(target)
|
|
398
|
+
|
|
399
|
+
stuck << { "id" => id, "roadmap" => candidate[:slug], "wave" => wave_heading_for(candidate, id),
|
|
400
|
+
"status" => "unreported",
|
|
401
|
+
"reason" => "#{id} needs #{target}, which the graph declares but no batch lists" }
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
reported + stuck
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def wave_heading_for(candidate, id)
|
|
409
|
+
candidate[:waves].each { |w| return w[:heading] if w[:entries].any? { |e| e[:id] == id } }
|
|
410
|
+
nil
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def all_graph_nodes(edges)
|
|
414
|
+
nodes = edges.keys.dup
|
|
415
|
+
edges.each_value { |targets| (targets || []).each { |t| nodes << t unless nodes.include?(t) } }
|
|
416
|
+
nodes
|
|
259
417
|
end
|
|
260
418
|
|
|
261
419
|
# --- scope + payload -----------------------------------------------------------
|
|
262
420
|
|
|
263
421
|
def scope_label
|
|
264
|
-
m = @roadmaps_dir.to_s.match(%r{/projects/([^/]+)/roadmaps/?\z})
|
|
265
|
-
m ? "project:#{m[1]}" : "global"
|
|
422
|
+
m = @roadmaps_dir.to_s.match(%r{/(?:projects|stores)/([^/]+)/roadmaps/?\z})
|
|
423
|
+
(m && m[1] != "global") ? "project:#{m[1]}" : "global"
|
|
266
424
|
end
|
|
267
425
|
|
|
268
426
|
def payload(mode:, state:, roadmap:, frontier_wave:, dispatchable:, in_flight:, blocked:, tie:, tie_candidates:)
|