@zalom/plastic 1.1.0 → 1.1.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 (63) hide show
  1. package/PLASTIC.md +12 -9
  2. package/README.md +3 -3
  3. package/agents/plastic-enforcer.md +8 -4
  4. package/agents/plastic-executor.md +5 -5
  5. package/agents/plastic-future-intent-researcher.md +1 -1
  6. package/agents/plastic-planner.md +15 -11
  7. package/hooks/check-update +1 -1
  8. package/hooks/continue +1 -1
  9. package/package.json +1 -1
  10. package/scripts/dashboard.rb +29 -24
  11. package/scripts/doctor.rb +180 -5
  12. package/scripts/hook-continue +3 -3
  13. package/scripts/install.rb +2 -1
  14. package/scripts/lib/bridge.rb +114 -11
  15. package/scripts/lib/dashboard_banner.rb +8 -9
  16. package/scripts/lib/installer_core.rb +12 -3
  17. package/scripts/lib/legacy_bookend_amnesty.rb +35 -0
  18. package/scripts/lib/release_guard.rb +62 -0
  19. package/scripts/lib/roadmap_queue.rb +285 -0
  20. package/scripts/lib/roadmap_savepoint.rb +213 -0
  21. package/scripts/lib/skill_lint.rb +304 -0
  22. package/scripts/lib/worktree.rb +21 -0
  23. package/scripts/new-intent +1 -0
  24. package/scripts/read-config +3 -3
  25. package/scripts/roadmap-next +44 -0
  26. package/scripts/roadmap-savepoint +64 -0
  27. package/scripts/skill-lint +50 -0
  28. package/skills/auto/SKILL.md +32 -11
  29. package/skills/auto/references/tiers.md +4 -3
  30. package/skills/continuing/SKILL.md +34 -0
  31. package/skills/continuing/evals/evals.json +91 -0
  32. package/skills/dashboard/SKILL.md +17 -14
  33. package/skills/dashboard/references/classification.md +3 -3
  34. package/skills/dashboard/templates/dashboard-global.md +8 -23
  35. package/skills/dashboard/templates/dashboard-project.md +7 -26
  36. package/skills/doctor/SKILL.md +1 -1
  37. package/skills/install/SKILL.md +10 -10
  38. package/skills/intent-continuing/SKILL.md +26 -68
  39. package/skills/intent-continuing/evals/evals.json +26 -26
  40. package/skills/intent-continuing/references/context-management.md +15 -19
  41. package/skills/intent-planning/SKILL.md +11 -11
  42. package/skills/intent-planning/evals/evals.json +20 -5
  43. package/skills/intent-planning/references/plan-format.md +9 -5
  44. package/skills/intent-savepoint/SKILL.md +12 -0
  45. package/skills/intent-starting/evals/evals.json +1 -1
  46. package/skills/project-continuing/SKILL.md +104 -0
  47. package/skills/project-continuing/evals/evals.json +100 -0
  48. package/skills/project-continuing/references/board-fill.md +33 -0
  49. package/skills/releasing/SKILL.md +48 -0
  50. package/skills/releasing/references/release-lines.md +105 -0
  51. package/skills/roadmap/SKILL.md +7 -1
  52. package/skills/roadmap/references/file-format.md +30 -1
  53. package/skills/roadmap/references/operations.md +26 -6
  54. package/skills/roadmap-continuing/SKILL.md +85 -0
  55. package/skills/roadmap-continuing/evals/evals.json +82 -0
  56. package/skills/roadmap-continuing/references/liveness-ranking.md +56 -0
  57. package/skills/skill-evaluating/evals/evals.json +1 -1
  58. package/skills/tutorial/references/track-1-guided.md +5 -4
  59. package/skills/tutorial/references/track-2-auto.md +1 -1
  60. package/skills/uninstall/SKILL.md +2 -2
  61. package/skills/update/SKILL.md +2 -2
  62. package/templates/config.yml +2 -1
  63. package/templates/index.md +4 -1
@@ -395,10 +395,24 @@ module Bridge
395
395
  File.exist?(path)
396
396
  end
397
397
 
398
+ # True iff actions/ holds AT LEAST ONE real action file: a non-empty *.md whose
399
+ # first line is not the placeholder sentinel. A `.gitkeep` (no .md extension)
400
+ # never counts, an empty *.md never counts, and a sentinel-only *.md never
401
+ # counts. Pure and side-effect-free so the gate stays unit-testable. Fail-open:
402
+ # a missing actions/ dir globs to nothing and returns false (the gate then
403
+ # reports it needs a real action file); it never raises.
404
+ def self.has_real_action?(intent_dir)
405
+ Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
406
+ File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
407
+ end
408
+ rescue StandardError
409
+ false
410
+ end
411
+
398
412
  def self.derive_stage(intent_dir)
399
413
  return "done" if stage_file_present?("#{intent_dir}/outcome.md")
400
414
  if stage_file_present?("#{intent_dir}/plan.md") &&
401
- File.directory?("#{intent_dir}/actions") &&
415
+ has_real_action?(intent_dir) &&
402
416
  stage_file_present?("#{intent_dir}/checklist.md")
403
417
  return "exec"
404
418
  end
@@ -414,7 +428,7 @@ module Bridge
414
428
  ["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
415
429
  files << f if stage_file_present?("#{intent_dir}/#{f}")
416
430
  end
417
- files << "actions/" if File.directory?("#{intent_dir}/actions")
431
+ files << "actions/" if has_real_action?(intent_dir)
418
432
  files
419
433
  end
420
434
 
@@ -640,6 +654,75 @@ module Bridge
640
654
  lines.length
641
655
  end
642
656
 
657
+ # --- Phantom-line detection (intent 134) ------------------------------------
658
+ #
659
+ # A companion to the ledger, not a new writer: pure, disk-only, hermetic (no bridge or
660
+ # session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
661
+ # a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
662
+ # can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
663
+ # repair is `rebuild_savepoint` (live intents) or the 124a manual Done-bookend recipe
664
+ # (terminal intents, human-granted only).
665
+
666
+ # (stage, milestone) -> basename, for every file-landing milestone this intent_dir could have
667
+ # produced (the intent file plus the four lifecycle artifacts). Reuses savepoint_milestone so
668
+ # the mapping never drifts from the one the writer itself uses.
669
+ def self.savepoint_file_landing_pairs(intent_dir)
670
+ basenames = [File.basename(intent_file(intent_dir)), "spec.md", "plan.md", "checklist.md", "outcome.md"]
671
+ basenames.each_with_object({}) do |basename, map|
672
+ pair = savepoint_milestone(intent_dir, basename)
673
+ map[pair] = basename if pair
674
+ end
675
+ end
676
+
677
+ # A `started` state line's real prerequisite is the PRECEDING stage's artifact, not its own
678
+ # (a `started` line legitimately fires before its own stage's file is real by design). `Exec
679
+ # started` additionally requires plan.md, since Exec cannot start before How produced it too.
680
+ SAVEPOINT_STATE_PREREQUISITES = {
681
+ ["How", "started"] => ["spec.md"],
682
+ ["Exec", "started"] => ["plan.md", "checklist.md"],
683
+ }.freeze
684
+
685
+ # Raw (stripped) ledger lines whose disk evidence contradicts them, each paired with a short
686
+ # reason: [line, reason]. Three phantom classes (D5):
687
+ # - a file-landing milestone whose file is absent or still a sentinel placeholder;
688
+ # - a duplicate (stage, milestone) pair (the later occurrence is the phantom);
689
+ # - a state line (`How started` / `Exec started`) whose stage prerequisites are absent.
690
+ # A clean ledger, or an absent one, returns [].
691
+ def self.savepoint_phantom_lines(intent_dir)
692
+ path = File.join(intent_dir, SAVEPOINT_FILE)
693
+ return [] unless File.exist?(path)
694
+
695
+ landing = savepoint_file_landing_pairs(intent_dir)
696
+ seen = []
697
+ phantoms = []
698
+
699
+ File.read(path).each_line do |raw|
700
+ line = raw.strip
701
+ next if line.empty?
702
+ parts = line.split(/\s{2,}/)
703
+ next if parts.length < 3
704
+ pair = [parts[1], parts[2]]
705
+
706
+ if seen.include?(pair)
707
+ phantoms << [line, "duplicate (stage, milestone) pair"]
708
+ next
709
+ end
710
+ seen << pair
711
+
712
+ if (basename = landing[pair]) && !stage_file_present?(File.join(intent_dir, basename))
713
+ phantoms << [line, "milestone file absent or still a sentinel placeholder"]
714
+ next
715
+ end
716
+
717
+ prereqs = SAVEPOINT_STATE_PREREQUISITES[pair]
718
+ if prereqs && prereqs.any? { |b| !stage_file_present?(File.join(intent_dir, b)) }
719
+ phantoms << [line, "state line prerequisite absent on disk"]
720
+ end
721
+ end
722
+
723
+ phantoms
724
+ end
725
+
643
726
  def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
644
727
  stage = derive_stage(intent_dir)
645
728
  has = has_files(intent_dir)
@@ -710,8 +793,13 @@ module Bridge
710
793
  return "Cannot start How — Why is incomplete (spec.md missing)"
711
794
  end
712
795
  when "checklist.md"
713
- unless stage_file_present?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
714
- return "Cannot complete How — plan.md or actions/ missing"
796
+ unless stage_file_present?("#{intent_dir}/plan.md")
797
+ return "Cannot complete How — plan.md missing"
798
+ end
799
+ unless has_real_action?(intent_dir)
800
+ return "Cannot complete How — actions/ has no real action file (only .gitkeep or empty). " \
801
+ "The planner must write at least one actions/ACTION_N.md before checklist.md. " \
802
+ "See skills/intent-planning."
715
803
  end
716
804
  when "outcome.md"
717
805
  checklist = "#{intent_dir}/checklist.md"
@@ -913,6 +1001,18 @@ module Bridge
913
1001
  name: name, tmp: tmp)
914
1002
  data["build"]["auto"] = auto
915
1003
  data["lock"] = lock_cache(lock_data)
1004
+
1005
+ # Provision the per-intent worktrees so the rebuilt bridge carries
1006
+ # worktree.code (intent 136). Without it, cwd/edited-path selection has no
1007
+ # key: the repaired intent loses its own code gate and a concurrent sibling
1008
+ # wins the tie-break. Idempotent (reuse dir / reattach branch) and fail-open
1009
+ # for non-git / global-only, exactly as `arm` does; never break the repair.
1010
+ begin
1011
+ Worktree.provision(data)
1012
+ rescue => e
1013
+ $stderr.puts "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
1014
+ end
1015
+
916
1016
  write(key, data, tmp: tmp)
917
1017
  actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
918
1018
 
@@ -936,11 +1036,14 @@ module Bridge
936
1036
  return nil unless store && dir
937
1037
  intent_dir_abs = File.expand_path("#{store}/#{dir}")
938
1038
 
939
- # "How reached" = the plan triplet exists. Gate by artifact presence, not the
940
- # stage label (derive_stage returns "how" as soon as spec.md exists, before any
941
- # plan). Code edits stay blocked until plan.md + checklist.md are both present.
1039
+ # "How reached" = plan.md + checklist.md are both present AND actions/ holds at
1040
+ # least one real action file. Gate by artifact presence, not the stage label
1041
+ # (derive_stage returns "how" as soon as spec.md exists, before any plan). Code
1042
+ # edits stay blocked until the planner has written a real action file, so an
1043
+ # empty or .gitkeep-only actions/ never opens the code gate.
942
1044
  reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
943
- stage_file_present?("#{intent_dir_abs}/checklist.md")
1045
+ stage_file_present?("#{intent_dir_abs}/checklist.md") &&
1046
+ has_real_action?(intent_dir_abs)
944
1047
  return nil if reached_how
945
1048
 
946
1049
  file_abs = File.expand_path(file_path.to_s)
@@ -949,9 +1052,9 @@ module Bridge
949
1052
  return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
950
1053
 
951
1054
  id = intent_info["id"]
952
- "intent #{id} has not reached How — write plan.md + checklist.md before " \
953
- "editing project code. Run plastic-auto or plastic-intent-planning first. " \
954
- "(blocked edit: #{file_abs})"
1055
+ "intent #{id} has not reached How — write plan.md + checklist.md and at least " \
1056
+ "one real actions/ACTION_N.md before editing project code. Run plastic-auto or " \
1057
+ "plastic-intent-planning first. (blocked edit: #{file_abs})"
955
1058
  end
956
1059
 
957
1060
  # --- Solo-mode detection (intent 128) ---------------------------------------
@@ -26,16 +26,15 @@ module DashboardBanner
26
26
  line
27
27
  end
28
28
 
29
- # The id of the top-ranked next_big candidate, when the payload's matrix carries
30
- # exactly the shape dashboard.rb emits (a "next_big" list of {id, ...} hashes,
31
- # already rank-sorted). Returns nil for any other shape rather than raising.
29
+ # The id of the top-ranked "drive" candidate, when the payload's next_work list
30
+ # carries the shape dashboard.rb emits (an Array of {id, disposition, ...}
31
+ # hashes, already rank-sorted). Returns nil for any other shape rather than
32
+ # raising.
32
33
  def next_big_thing_id(payload)
33
- matrix = payload["matrix"]
34
- return nil unless matrix.is_a?(Hash)
35
- list = matrix["next_big"]
36
- return nil unless list.is_a?(Array) && !list.empty?
37
- top = list.first
38
- return nil unless top.is_a?(Hash)
34
+ list = payload["next_work"]
35
+ return nil unless list.is_a?(Array)
36
+ top = list.find { |entry| entry.is_a?(Hash) && entry["disposition"] == "drive" }
37
+ return nil unless top
39
38
  id = top["id"].to_s
40
39
  id.empty? ? nil : id
41
40
  end
@@ -12,7 +12,7 @@ require_relative "agent_models"
12
12
  # Shared installer machinery, instantiable with injected package root / store / agent
13
13
  # map so the verb scripts (install/update/uninstall/rollback) and their tests can run
14
14
  # hermetically (no eval, no global-constant rewriting). Mirrors the DI recipe proven in
15
- # doctor.rb / install.rb (intents 30a, 30a1). Library only no CLI, no $PROGRAM_NAME guard.
15
+ # doctor.rb / install.rb (intents 30a, 30a1). Library only - no CLI, no $PROGRAM_NAME guard.
16
16
  class InstallerCore
17
17
  DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
18
18
 
@@ -54,7 +54,7 @@ class InstallerCore
54
54
  STABILITY[ch] || 2
55
55
  end
56
56
 
57
- # --- Semver (§11) parse/compare, shared by update + rollback ---
57
+ # --- Semver (§11) - parse/compare, shared by update + rollback ---
58
58
 
59
59
  def semver_parse(version)
60
60
  m = /\A(\d+)\.(\d+)\.(\d+)(?:-(.+))?\z/.match(version.to_s.strip)
@@ -95,7 +95,7 @@ class InstallerCore
95
95
  semver_compare(a, b) == 1
96
96
  end
97
97
 
98
- # --- versions.json ledger (append-only JSONL one object per line) ---
98
+ # --- versions.json ledger (append-only JSONL - one object per line) ---
99
99
 
100
100
  def ledger_path
101
101
  File.join(plastic_home, "versions.json")
@@ -242,6 +242,7 @@ class InstallerCore
242
242
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
243
243
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
244
244
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
245
+ "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
245
246
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
246
247
  "scripts/hook-lock-gate" => "scripts/hook-lock-gate",
247
248
  "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
@@ -260,6 +261,10 @@ class InstallerCore
260
261
  "scripts/lib/dashboard_banner.rb" => "scripts/lib/dashboard_banner.rb",
261
262
  "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
262
263
  "scripts/qmd-sync" => "scripts/qmd-sync",
264
+ "scripts/lib/roadmap_savepoint.rb" => "scripts/lib/roadmap_savepoint.rb",
265
+ "scripts/roadmap-savepoint" => "scripts/roadmap-savepoint",
266
+ "scripts/lib/roadmap_queue.rb" => "scripts/lib/roadmap_queue.rb",
267
+ "scripts/roadmap-next" => "scripts/roadmap-next",
263
268
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
264
269
  "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
265
270
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
@@ -271,6 +276,7 @@ class InstallerCore
271
276
  "scripts/rebuild-graph" => "scripts/rebuild-graph",
272
277
  "scripts/validate-intent" => "scripts/validate-intent",
273
278
  "scripts/new-intent" => "scripts/new-intent",
279
+ "scripts/end-intent" => "scripts/end-intent",
274
280
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
275
281
  "templates/intent.md" => "templates/intent.md",
276
282
  "templates/spec.md" => "templates/spec.md",
@@ -287,8 +293,11 @@ class InstallerCore
287
293
  "scripts/update.rb" => "scripts/update.rb",
288
294
  "scripts/uninstall.rb" => "scripts/uninstall.rb",
289
295
  "scripts/rollback.rb" => "scripts/rollback.rb",
296
+ "scripts/lib/legacy_bookend_amnesty.rb" => "scripts/lib/legacy_bookend_amnesty.rb",
290
297
  "scripts/doctor.rb" => "scripts/doctor.rb",
291
298
  "scripts/dashboard.rb" => "scripts/dashboard.rb",
299
+ "scripts/skill-lint" => "scripts/skill-lint",
300
+ "scripts/lib/skill_lint.rb" => "scripts/lib/skill_lint.rb",
292
301
  }
293
302
  end
294
303
 
@@ -0,0 +1,35 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Intent 170a - A2 cutoff amnesty for the legacy savepoint Done-bookend gap.
5
+ #
6
+ # Frozen 2026-07-10. Pre-161 terminal intents predate
7
+ # Bridge.append_terminal_savepoint, so their savepoint.md never got a
8
+ # `Done delivered|abandoned` line. This list grandfathers exactly those,
9
+ # keyed by store scope plus intent id, so doctor's signals_complete check
10
+ # stops counting them as gaps. Every intent NOT on this list still warns if
11
+ # its savepoint lacks the Done bookend, including any new terminal intent
12
+ # going forward. This is a frozen historical snapshot: it must never grow.
13
+ # Regenerating it requires re-running the exact predicate below against the
14
+ # real store and reviewing the diff, never appending ad hoc.
15
+ #
16
+ # Predicate used to build this list (2026-07-10): for each store in
17
+ # Doctor#done_signal_stores(nil), each dir in index_sections_by_dir(index),
18
+ # terminal = the dir's INDEX section is Completed or Abandoned, gap =
19
+ # savepoint.md exists AND does not match
20
+ # /\bDone\b.*\b(delivered|abandoned)\b/.
21
+ #
22
+ # This does NOT grandfather the separate outcome.md completeness gap
23
+ # (checked independently at scripts/doctor.rb:603-607); some of these ids
24
+ # may still warn on that axis.
25
+ module LegacyBookendAmnesty
26
+ LIST = {
27
+ "global" => %w[1a 1a2 23].freeze,
28
+ "project:plastic" => %w[
29
+ 1 11 121a 124 128 13 13b 15 158 158a 159 160 163 1a 1b1a3 22 30a1a 34
30
+ 36a 36a1 37 38 39 45 45a 49 4a 4a1 4a1c1 50 52 54 55 56 58 59 60b 65
31
+ 66 66a 66b 66c 66c1 67 68 71 72 73b 73c 73c1 73c2 73c3 74 77 79 80 83
32
+ 84 85a 9
33
+ ].freeze,
34
+ }.freeze
35
+ end
@@ -0,0 +1,62 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ # Mechanical guard for stable-cut version preconditions (intent 155). Checks
7
+ # that the three repo version files agree on one version string, and, when a
8
+ # stable/latest cut is declared, that the resolved version carries no
9
+ # pre-release suffix. Pure function over injected paths: no ENV reads, no
10
+ # eval, no global-config seam, hermetically testable and safe to call from
11
+ # both the release workflow and the test suite.
12
+ #
13
+ # Deliberately does not check a repo VERSION file: none exists in this repo.
14
+ # VERSION is an install-target artifact written fresh from package.json at
15
+ # install/update time (scripts/lib/installer_core.rb); it cannot drift
16
+ # independently because it is never committed.
17
+ module ReleaseGuard
18
+ Result = Struct.new(:ok, :version, :mismatches, :prerelease_suffix, keyword_init: true) do
19
+ def ok?
20
+ ok
21
+ end
22
+ end
23
+
24
+ # package_json / plugin_json / marketplace_json: paths to the three repo
25
+ # version files. stable: true gates a stable/latest cut (rejects any
26
+ # pre-release suffix); false allows a suffix, only agreement is checked.
27
+ def self.check(package_json:, plugin_json:, marketplace_json:, stable:)
28
+ versions = {
29
+ "package.json" => read_version(package_json) { |data| data["version"] },
30
+ ".claude-plugin/plugin.json" => read_version(plugin_json) { |data| data["version"] },
31
+ ".claude-plugin/marketplace.json" => read_version(marketplace_json) { |data| plastic_plugin_version(data) },
32
+ }
33
+
34
+ canonical = versions["package.json"]
35
+ mismatches = versions.reject { |_file, version| version && version == canonical }.keys
36
+
37
+ suffix = canonical&.match(/-(.+)\z/)&.captures&.first
38
+ prerelease_violation = stable && !suffix.nil?
39
+
40
+ Result.new(
41
+ ok: mismatches.empty? && !prerelease_violation,
42
+ version: canonical,
43
+ mismatches: mismatches,
44
+ prerelease_suffix: suffix
45
+ )
46
+ end
47
+
48
+ def self.plastic_plugin_version(data)
49
+ plugins = Array(data["plugins"])
50
+ plugin = plugins.find { |p| p["name"] == "plastic" } || plugins.first
51
+ plugin && plugin["version"]
52
+ end
53
+ private_class_method :plastic_plugin_version
54
+
55
+ def self.read_version(path)
56
+ data = JSON.parse(File.read(path))
57
+ yield data
58
+ rescue Errno::ENOENT, JSON::ParserError
59
+ nil
60
+ end
61
+ private_class_method :read_version
62
+ end
@@ -0,0 +1,285 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require "json"
6
+ require_relative "roadmap_savepoint"
7
+
8
+ # FileOrderRanker - the default value-ordering strategy: today's roadmap file order,
9
+ # unchanged. This is the intent-173 ranking-swap seam (sibling to the 147 DB-swap seam): a
10
+ # future scored ranker (RICE/ICE/WSJF/pairwise) implements the same #rank/#name pair and is
11
+ # injected through RoadmapQueue's ranker: keyword, with no change to parsing, frontier
12
+ # detection, gating, INDEX reconciliation, or the JSON contract.
13
+ class FileOrderRanker
14
+ def rank(entries)
15
+ entries
16
+ end
17
+
18
+ def name
19
+ "file-order"
20
+ end
21
+ end
22
+
23
+ # RoadmapQueue - the one deterministic reader the auto loop and plastic-roadmap-continuing
24
+ # both call (intent 148). Constructor-DI, hermetic: clock and paths injected, no eval, no ENV
25
+ # or global config seam. It does two things: liveness-ranks a tier's roadmaps/*.md files
26
+ # (porting plastic-roadmap-continuing's read-time algorithm), and, within the winning
27
+ # roadmap, selects the frontier wave plus its dispatchable set (D-b), value-ordered by the
28
+ # injected ranker (default FileOrderRanker, the intent-173 swap seam). Every frontier token is
29
+ # reconciled against INDEX.md first, INDEX wins. Reads through the 134 ledger via the public
30
+ # RoadmapSavepoint.ledger_path_for; never writes anything, never modifies roadmap_savepoint.rb.
31
+ class RoadmapQueue
32
+ STATUSES = %w[queued delivering delivered abandoned blocked].freeze
33
+
34
+ # Entry line parser, anchored on the status vocabulary rather than end of line, so a trailing
35
+ # parenthetical ("delivering (owner ruling...)") does not defeat the match. Accepts the em
36
+ # 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
38
+
39
+ WAVE_HEADING = /\A###\s+(.+?)\s*\z/.freeze
40
+
41
+ LOG_LINE = /\A-\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+UTC\b/.freeze
42
+
43
+ def initialize(roadmaps_dir:, index_path: nil, now: Time.now, ranker: FileOrderRanker.new)
44
+ @roadmaps_dir = roadmaps_dir
45
+ @index_path = index_path
46
+ @now = now
47
+ @ranker = ranker
48
+ end
49
+
50
+ # Auto-loop mode: break ties deterministically, report the winner's frontier state.
51
+ def queue
52
+ analyze(mode: "queue")
53
+ end
54
+
55
+ # Continuing mode: return tie_candidates instead of breaking a tie.
56
+ def which
57
+ analyze(mode: "which")
58
+ end
59
+
60
+ private
61
+
62
+ def analyze(mode:)
63
+ parsed = reconcile(roadmap_paths.map { |path| parse_roadmap(path) })
64
+ ranked = rank_candidates(parsed)
65
+
66
+ return payload(mode: mode, state: "none", roadmap: nil, frontier_wave: nil,
67
+ dispatchable: [], in_flight: [], blocked: [], tie: false,
68
+ tie_candidates: []) if ranked.empty?
69
+
70
+ tied = tied_group(ranked)
71
+
72
+ if tied.length > 1 && mode == "which"
73
+ tie_candidates = tied.map do |c|
74
+ { "roadmap" => c[:slug], "last_event" => c[:last_event].utc.iso8601,
75
+ "reason" => "equally live, tied on last event time" }
76
+ end
77
+ return payload(mode: mode, state: "tie", roadmap: nil, frontier_wave: nil,
78
+ dispatchable: [], in_flight: [], blocked: [], tie: false,
79
+ tie_candidates: tie_candidates)
80
+ end
81
+
82
+ winner = ranked.first
83
+ is_tie = tied.length > 1
84
+ frontier = frontier_for(winner)
85
+
86
+ state =
87
+ if frontier.nil?
88
+ "exhausted"
89
+ elsif !frontier[:dispatchable].empty?
90
+ "dispatchable"
91
+ else
92
+ "in_flight"
93
+ end
94
+
95
+ payload(mode: mode, state: state, roadmap: winner[:slug],
96
+ frontier_wave: frontier && frontier[:heading],
97
+ dispatchable: frontier ? frontier[:dispatchable] : [],
98
+ in_flight: frontier ? frontier[:in_flight] : [],
99
+ blocked: blocked_for(winner),
100
+ tie: is_tie,
101
+ tie_candidates: [])
102
+ end
103
+
104
+ # --- enumerate + parse --------------------------------------------------------
105
+
106
+ def roadmap_paths
107
+ return [] unless @roadmaps_dir && Dir.exist?(@roadmaps_dir)
108
+ Dir.glob(File.join(@roadmaps_dir, "*.md"))
109
+ .reject { |p| p.end_with?(".savepoint.md") }
110
+ .sort
111
+ end
112
+
113
+ def parse_roadmap(path)
114
+ text = File.read(path)
115
+ { slug: File.basename(path, ".md"), path: path, waves: parse_waves(section_body(text, "Waves")) }
116
+ end
117
+
118
+ def parse_waves(waves_body)
119
+ waves = []
120
+ current = nil
121
+ waves_body.each_line do |line|
122
+ stripped = line.chomp.strip
123
+ if (m = stripped.match(WAVE_HEADING))
124
+ current = { heading: m[1], entries: [] }
125
+ waves << current
126
+ elsif current && (em = stripped.match(ENTRY))
127
+ current[:entries] << { id: em[2], raw_status: em[3].downcase }
128
+ end
129
+ end
130
+ waves
131
+ end
132
+
133
+ def section_body(text, heading)
134
+ m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
135
+ m ? m[1] : ""
136
+ end
137
+
138
+ # --- INDEX reconciliation (INDEX wins), applied before classification --------
139
+
140
+ def reconcile(parsed_list)
141
+ parsed_list.each do |c|
142
+ c[:waves].each do |wave|
143
+ wave[:entries].each { |entry| entry[:status] = reconcile_status(entry[:id], entry[:raw_status]) }
144
+ end
145
+ end
146
+ parsed_list
147
+ end
148
+
149
+ def reconcile_status(id, raw_status)
150
+ case index_status_map[id]
151
+ when "delivered" then "delivered"
152
+ when "abandoned" then "abandoned"
153
+ when "queued" then "queued"
154
+ when :active then raw_status == "delivered" ? "delivering" : raw_status
155
+ else raw_status
156
+ end
157
+ end
158
+
159
+ def index_status_map
160
+ return @index_status_map if defined?(@index_status_map)
161
+ @index_status_map = {}
162
+ path = resolved_index_path
163
+ return @index_status_map unless path && File.exist?(path)
164
+
165
+ text = File.read(path)
166
+ { "Completed" => "delivered", "Abandoned" => "abandoned", "Active" => :active, "Future" => "queued" }.each do |heading, tag|
167
+ section_body(text, heading).each_line do |line|
168
+ stripped = line.strip
169
+ next unless stripped.start_with?("- [")
170
+ m = stripped.match(/\A-\s*\[(\S+)\s/)
171
+ next unless m
172
+ @index_status_map[m[1]] = tag
173
+ end
174
+ end
175
+ @index_status_map
176
+ end
177
+
178
+ def resolved_index_path
179
+ return @index_path if @index_path
180
+ return nil unless @roadmaps_dir
181
+ File.join(File.dirname(@roadmaps_dir), "INDEX.md")
182
+ end
183
+
184
+ # --- liveness ranking (ports plastic-roadmap-continuing's read-time algorithm) -
185
+
186
+ def rank_candidates(parsed_list)
187
+ parsed_list.map do |c|
188
+ entries = c[:waves].flat_map { |w| w[:entries] }
189
+ 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]] }
192
+ end
193
+
194
+ def tied_group(ranked)
195
+ 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 }
198
+ end
199
+
200
+ def last_event_time(path)
201
+ ledger_path = RoadmapSavepoint.ledger_path_for(path)
202
+ if File.exist?(ledger_path)
203
+ last_line = File.readlines(ledger_path).map(&:strip).reject(&:empty?).last
204
+ if last_line
205
+ token = last_line[/\A(\S+)/, 1]
206
+ begin
207
+ return Time.iso8601(token) if token
208
+ rescue ArgumentError
209
+ # fall through to the Log fallback below
210
+ end
211
+ end
212
+ end
213
+ log_fallback_time(path)
214
+ end
215
+
216
+ def log_fallback_time(path)
217
+ body = section_body(File.read(path), "Log")
218
+ last = body.each_line.map { |l| l.chomp.strip }.select { |l| l.match?(LOG_LINE) }.last
219
+ return Time.at(0) unless last
220
+
221
+ m = last.match(LOG_LINE)
222
+ y, mo, d = m[1].split("-").map(&:to_i)
223
+ h, mi = m[2].split(":").map(&:to_i)
224
+ Time.utc(y, mo, d, h, mi, 0)
225
+ end
226
+
227
+ # --- frontier + dispatchable selection (D-b) ----------------------------------
228
+
229
+ def frontier_for(candidate)
230
+ candidate[:waves].each do |wave|
231
+ statuses = wave[:entries].map { |e| e[:status] }
232
+ next unless statuses.any? { |s| %w[queued delivering].include?(s) }
233
+
234
+ queued = wave[:entries].select { |e| e[:status] == "queued" }
235
+ delivering = wave[:entries].select { |e| e[:status] == "delivering" }
236
+
237
+ # intent-173 ranking-swap seam: value-orders the dispatchable candidates only.
238
+ ordered = @ranker.rank(queued)
239
+
240
+ dispatchable = ordered.each_with_index.map do |e, i|
241
+ { "id" => e[:id], "scope" => scope_label, "roadmap" => candidate[:slug],
242
+ "wave" => wave[:heading], "status" => "queued", "rank" => i + 1 }
243
+ end
244
+ in_flight = delivering.map do |e|
245
+ { "id" => e[:id], "roadmap" => candidate[:slug], "wave" => wave[:heading], "status" => "delivering" }
246
+ end
247
+
248
+ return { heading: wave[:heading], dispatchable: dispatchable, in_flight: in_flight }
249
+ end
250
+ nil
251
+ end
252
+
253
+ def blocked_for(candidate)
254
+ candidate[:waves].flat_map do |wave|
255
+ wave[:entries].select { |e| e[:status] == "blocked" }.map do |e|
256
+ { "id" => e[:id], "roadmap" => candidate[:slug], "wave" => wave[:heading], "status" => "blocked" }
257
+ end
258
+ end
259
+ end
260
+
261
+ # --- scope + payload -----------------------------------------------------------
262
+
263
+ def scope_label
264
+ m = @roadmaps_dir.to_s.match(%r{/projects/([^/]+)/roadmaps/?\z})
265
+ m ? "project:#{m[1]}" : "global"
266
+ end
267
+
268
+ def payload(mode:, state:, roadmap:, frontier_wave:, dispatchable:, in_flight:, blocked:, tie:, tie_candidates:)
269
+ {
270
+ "generated_for" => "roadmap-next",
271
+ "mode" => mode,
272
+ "scope" => scope_label,
273
+ "state" => state,
274
+ "roadmap" => roadmap,
275
+ "frontier_wave" => frontier_wave,
276
+ "dispatchable_queue" => dispatchable,
277
+ "in_flight" => in_flight,
278
+ "blocked" => blocked,
279
+ "tie" => tie,
280
+ "tie_candidates" => tie_candidates,
281
+ "ranking_strategy" => @ranker.name,
282
+ "generated_at" => @now.utc.iso8601,
283
+ }
284
+ end
285
+ end