@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.
Files changed (326) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +21 -16
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +8 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/docs/help/completion-and-done.md +53 -0
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +5 -4
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +55 -2
  34. package/hooks/session-start +5 -1
  35. package/hooks/statusline +32 -27
  36. package/hooks/stop +5 -0
  37. package/package.json +5 -3
  38. package/scripts/append-ledger +2 -1
  39. package/scripts/dashboard.rb +267 -16
  40. package/scripts/day-summary +2 -1
  41. package/scripts/doctor.rb +680 -39
  42. package/scripts/end-intent +378 -56
  43. package/scripts/exec-worktree +5 -5
  44. package/scripts/file-session-intent +2 -1
  45. package/scripts/graph-measure +249 -0
  46. package/scripts/hook-call-budget +222 -0
  47. package/scripts/hook-capture +24 -123
  48. package/scripts/hook-close +2 -1
  49. package/scripts/hook-message-display +7 -0
  50. package/scripts/hook-record +24 -16
  51. package/scripts/hook-savepoint +27 -3
  52. package/scripts/hook-session-start +359 -321
  53. package/scripts/hook-stop +58 -0
  54. package/scripts/index-projection +74 -0
  55. package/scripts/insight-append +17 -4
  56. package/scripts/install.rb +9 -7
  57. package/scripts/lib/action_graph_shim.rb +279 -0
  58. package/scripts/lib/active_delivery.rb +105 -0
  59. package/scripts/lib/agent_models.rb +63 -25
  60. package/scripts/lib/arm.rb +74 -91
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +111 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +68 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +58 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +75 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +79 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +46 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +95 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +35 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +77 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +65 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +55 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +23 -14
  130. package/scripts/lib/doctor_core.rb +114 -38
  131. package/scripts/lib/doctor_session_ledger.rb +5 -53
  132. package/scripts/lib/engine_permissions.rb +88 -0
  133. package/scripts/lib/exec_worktree.rb +24 -21
  134. package/scripts/lib/feedback_report.rb +1 -1
  135. package/scripts/lib/graph_edges.rb +137 -0
  136. package/scripts/lib/graph_file.rb +246 -0
  137. package/scripts/lib/graph_measure.rb +645 -0
  138. package/scripts/lib/graph_measure_budget.rb +409 -0
  139. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  140. package/scripts/lib/graph_measure_models.rb +413 -0
  141. package/scripts/lib/graph_measure_report.rb +532 -0
  142. package/scripts/lib/graph_tree.rb +98 -0
  143. package/scripts/lib/guarded_append.rb +155 -0
  144. package/scripts/lib/handoff.rb +40 -13
  145. package/scripts/lib/harness_adapter.rb +184 -0
  146. package/scripts/lib/hook_registry.rb +27 -3
  147. package/scripts/lib/hook_replay.rb +235 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +543 -71
  152. package/scripts/lib/intent_screen.rb +4 -4
  153. package/scripts/lib/intent_screen_ansi.rb +73 -12
  154. package/scripts/lib/intent_validator.rb +2 -2
  155. package/scripts/lib/lock.rb +10 -11
  156. package/scripts/lib/message_display.rb +350 -54
  157. package/scripts/lib/meter_watch.rb +185 -0
  158. package/scripts/lib/node_file.rb +234 -0
  159. package/scripts/lib/node_ids.rb +99 -0
  160. package/scripts/lib/node_input.rb +921 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +1371 -47
  175. package/scripts/lib/rlm/corpus.rb +13 -0
  176. package/scripts/lib/rlm/probe.rb +29 -0
  177. package/scripts/lib/rlm/query.rb +22 -0
  178. package/scripts/lib/roadmap_graph.rb +210 -0
  179. package/scripts/lib/roadmap_migration.rb +95 -0
  180. package/scripts/lib/roadmap_queue.rb +170 -12
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +526 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +365 -28
  196. package/scripts/lib/screens/dashboard.rb +20 -0
  197. package/scripts/lib/screens/plan.rb +18 -0
  198. package/scripts/lib/screens/roadmap.rb +15 -0
  199. package/scripts/lib/search_index.rb +55 -0
  200. package/scripts/lib/session_close.rb +30 -28
  201. package/scripts/lib/session_git.rb +67 -19
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/untouched_scaffold.rb +51 -0
  212. package/scripts/lib/verify_intent.rb +36 -8
  213. package/scripts/lib/version_number.rb +48 -0
  214. package/scripts/lib/work_graph.rb +59 -0
  215. package/scripts/lib/work_graph_validator.rb +201 -0
  216. package/scripts/lib/worktree.rb +27 -32
  217. package/scripts/lib/worktree_sweep.rb +6 -5
  218. package/scripts/link-suggest +2 -1
  219. package/scripts/meter-watch +57 -0
  220. package/scripts/migrate-to-global +1 -1
  221. package/scripts/new-intent +4 -13
  222. package/scripts/node-input +92 -0
  223. package/scripts/node-run +225 -0
  224. package/scripts/node-transition +291 -0
  225. package/scripts/outcome-report +74 -0
  226. package/scripts/plastic-lock +65 -60
  227. package/scripts/project-links +6 -21
  228. package/scripts/promote-session-item +3 -2
  229. package/scripts/read-config +53 -9
  230. package/scripts/ready-set +126 -0
  231. package/scripts/release-check +123 -0
  232. package/scripts/report-screen +177 -16
  233. package/scripts/roadmap-graph +125 -0
  234. package/scripts/roadmap-savepoint +7 -0
  235. package/scripts/rollback.rb +5 -1
  236. package/scripts/runner +581 -0
  237. package/scripts/savepoint-note +11 -9
  238. package/scripts/session-commit +2 -1
  239. package/scripts/session-usage +56 -0
  240. package/scripts/skill-lint +115 -6
  241. package/scripts/spawn-preamble +2 -2
  242. package/scripts/update.rb +31 -4
  243. package/scripts/validate-work-graph +39 -0
  244. package/scripts/verify-intent +3 -2
  245. package/scripts/write-handoff +2 -1
  246. package/templates/agents.md +7 -7
  247. package/templates/config.yml +16 -9
  248. package/templates/dashboard-screen.md +22 -0
  249. package/templates/display-fixture.md +21 -0
  250. package/templates/graph.md +16 -0
  251. package/templates/index.md +1 -1
  252. package/templates/intent-screen.md +1 -1
  253. package/templates/node-decision.md +11 -0
  254. package/templates/node-research.md +13 -0
  255. package/templates/node-verify.md +13 -0
  256. package/templates/node-work.md +22 -0
  257. package/templates/outcome.md +8 -3
  258. package/templates/project.yml +1 -1
  259. package/templates/render.css +10 -0
  260. package/templates/report-plan.md +15 -0
  261. package/templates/report-roadmap-delivered.md +10 -0
  262. package/templates/report-roadmap-plan.md +9 -0
  263. package/templates/report-roadmap-state.md +9 -0
  264. package/templates/report-state.md +1 -1
  265. package/templates/roadmap.md +13 -0
  266. package/bin/plastic.js +0 -70
  267. package/scripts/lib/bridge.rb +0 -116
  268. package/skills/agent-advisor/SKILL.md +0 -92
  269. package/skills/auto/SKILL.md +0 -296
  270. package/skills/auto/evals/evals.json +0 -255
  271. package/skills/auto/references/end-tail.md +0 -66
  272. package/skills/auto/references/human-report-contract.md +0 -78
  273. package/skills/conventions/SKILL.md +0 -29
  274. package/skills/conventions/references/completion-and-done.md +0 -43
  275. package/skills/dashboard/SKILL.md +0 -169
  276. package/skills/dashboard/evals/evals.json +0 -38
  277. package/skills/dashboard/references/classification.md +0 -22
  278. package/skills/dashboard/templates/dashboard-global.md +0 -20
  279. package/skills/dashboard/templates/dashboard-project.md +0 -19
  280. package/skills/direct/SKILL.md +0 -66
  281. package/skills/direct/references/request-signals.md +0 -59
  282. package/skills/doctor/SKILL.md +0 -299
  283. package/skills/doctor/report.md +0 -102
  284. package/skills/feedback/SKILL.md +0 -98
  285. package/skills/feedback/references/transport-and-privacy.md +0 -65
  286. package/skills/feedback/report.md +0 -36
  287. package/skills/install/SKILL.md +0 -217
  288. package/skills/intent-continuing/SKILL.md +0 -154
  289. package/skills/intent-continuing/references/board-fill.md +0 -43
  290. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  291. package/skills/intent-continuing/references/context-management.md +0 -28
  292. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  293. package/skills/intent-creating/SKILL.md +0 -164
  294. package/skills/intent-creating/evals/evals.json +0 -72
  295. package/skills/intent-creating/references/lifecycle.md +0 -81
  296. package/skills/intent-creating/references/wikilinks.md +0 -8
  297. package/skills/intent-ending/SKILL.md +0 -176
  298. package/skills/intent-ending/evals/evals.json +0 -74
  299. package/skills/intent-executing/SKILL.md +0 -170
  300. package/skills/intent-executing/evals/evals.json +0 -66
  301. package/skills/intent-executing/implementer-prompt.md +0 -42
  302. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  303. package/skills/intent-speccing/SKILL.md +0 -130
  304. package/skills/intent-speccing/evals/evals.json +0 -126
  305. package/skills/intent-speccing/references/design-principles.md +0 -44
  306. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  307. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  308. package/skills/project-creating/SKILL.md +0 -162
  309. package/skills/project-creating/references/hubs-projects.md +0 -55
  310. package/skills/project-creating/references/project-scaffolding.md +0 -97
  311. package/skills/releasing/SKILL.md +0 -337
  312. package/skills/releasing/references/deprecations.md +0 -60
  313. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  314. package/skills/releasing/references/release-lines.md +0 -105
  315. package/skills/roadmap/SKILL.md +0 -64
  316. package/skills/roadmap/references/file-format.md +0 -124
  317. package/skills/roadmap/references/operations.md +0 -112
  318. package/skills/rollback/SKILL.md +0 -91
  319. package/skills/tutorial/SKILL.md +0 -65
  320. package/skills/tutorial/evals/evals.json +0 -186
  321. package/skills/uninstall/SKILL.md +0 -75
  322. package/skills/update/SKILL.md +0 -126
  323. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  324. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  325. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  326. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RLM
4
+ class Corpus
5
+ def initialize(search)
6
+ @search = search
7
+ end
8
+
9
+ def call(terms, limit)
10
+ @search.call(terms, limit)
11
+ end
12
+ end
13
+ end
@@ -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
- ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+.*?[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
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
- { slug: File.basename(path, ".md"), path: path, waves: parse_waves(RoadmapSavepoint.grouping_section_body(text, path: path)) }
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], raw_status: em[3].downcase }
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.merge(live: live, last_event: last_event_time(c[:path]))
191
- end.sort_by { |c| [c[:live] ? 0 : 1, -c[:last_event].to_i, c[:slug]] }
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 = [ranked.first[:live], ranked.first[:last_event].to_i]
197
- ranked.select { |c| [c[:live], c[:last_event].to_i] == top_key }
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:)