@zalom/plastic 1.0.3 → 1.1.1

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 (130) hide show
  1. package/PLASTIC-reference.md +11 -11
  2. package/PLASTIC.md +11 -9
  3. package/README.md +48 -1
  4. package/agents/plastic-enforcer.md +8 -5
  5. package/agents/plastic-future-intent-researcher.md +1 -1
  6. package/agents/plastic-intent-curator.md +9 -10
  7. package/agents/plastic-spec-specialist.md +9 -6
  8. package/bin/plastic.js +2 -2
  9. package/hooks/check-update +1 -1
  10. package/hooks/continue +1 -1
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +29 -24
  13. package/scripts/doctor.rb +188 -9
  14. package/scripts/end-intent +330 -0
  15. package/scripts/hook-code-gate +1 -1
  16. package/scripts/hook-continue +2 -2
  17. package/scripts/hook-create-gate +2 -2
  18. package/scripts/hook-session-start +1 -1
  19. package/scripts/install.rb +2 -1
  20. package/scripts/lib/bridge.rb +116 -3
  21. package/scripts/lib/dashboard_banner.rb +8 -9
  22. package/scripts/lib/installer_core.rb +17 -8
  23. package/scripts/lib/legacy_bookend_amnesty.rb +35 -0
  24. package/scripts/lib/release_guard.rb +62 -0
  25. package/scripts/lib/roadmap_queue.rb +285 -0
  26. package/scripts/lib/roadmap_savepoint.rb +213 -0
  27. package/scripts/lib/store_provisioning.rb +1 -1
  28. package/scripts/lib/worktree.rb +21 -0
  29. package/scripts/new-intent +2 -1
  30. package/scripts/read-config +3 -3
  31. package/scripts/roadmap-next +44 -0
  32. package/scripts/roadmap-savepoint +64 -0
  33. package/scripts/{versions.rb → rollback.rb} +33 -41
  34. package/scripts/update.rb +1 -1
  35. package/skills/_active-intent-gate.md +1 -1
  36. package/skills/_decision-tables.md +50 -0
  37. package/skills/auto/SKILL.md +69 -68
  38. package/skills/auto/evals/evals.json +4 -4
  39. package/skills/auto/references/agent-architecture.md +2 -2
  40. package/skills/continuing/SKILL.md +23 -130
  41. package/skills/continuing/evals/evals.json +26 -80
  42. package/skills/dashboard/SKILL.md +18 -15
  43. package/skills/dashboard/references/classification.md +3 -3
  44. package/skills/dashboard/templates/dashboard-global.md +8 -23
  45. package/skills/dashboard/templates/dashboard-project.md +7 -26
  46. package/skills/doctor/SKILL.md +3 -3
  47. package/skills/install/SKILL.md +10 -10
  48. package/skills/{brainstorming → intent-brainstorming}/SKILL.md +43 -45
  49. package/skills/intent-brainstorming/evals/evals.json +67 -0
  50. package/skills/{brainstorming → intent-brainstorming}/references/design-principles.md +12 -17
  51. package/skills/intent-continuing/SKILL.md +99 -0
  52. package/skills/intent-continuing/evals/evals.json +145 -0
  53. package/skills/intent-continuing/references/context-management.md +28 -0
  54. package/skills/{creating-intent → intent-creating}/SKILL.md +3 -3
  55. package/skills/{creating-intent → intent-creating}/evals/evals.json +1 -1
  56. package/skills/{intent-discovery → intent-discovering}/SKILL.md +1 -1
  57. package/skills/intent-ending/SKILL.md +151 -0
  58. package/skills/intent-ending/evals/evals.json +74 -0
  59. package/skills/{executing-plan → intent-executing}/SKILL.md +75 -15
  60. package/skills/intent-executing/evals/evals.json +66 -0
  61. package/skills/{brainstorming-grill-me → intent-grilling}/SKILL.md +4 -4
  62. package/skills/{linking-intents → intent-linking}/SKILL.md +1 -1
  63. package/skills/{linking-intents → intent-linking}/evals/evals.json +1 -1
  64. package/skills/{lock → intent-locking}/SKILL.md +1 -1
  65. package/skills/{writing-plans → intent-planning}/SKILL.md +48 -20
  66. package/skills/intent-planning/evals/evals.json +66 -0
  67. package/skills/{writing-plans → intent-planning}/references/plan-format.md +31 -7
  68. package/skills/{research → intent-researching}/SKILL.md +2 -2
  69. package/skills/{research → intent-researching}/evals/evals.json +1 -1
  70. package/skills/{savepoint → intent-savepoint}/SKILL.md +13 -1
  71. package/skills/intent-speccing/SKILL.md +69 -0
  72. package/skills/intent-speccing/evals/evals.json +126 -0
  73. package/skills/intent-speccing/references/per-section-fill-rules.md +92 -0
  74. package/skills/intent-speccing/references/self-verify-checklist.md +38 -0
  75. package/skills/intent-starting/SKILL.md +2 -2
  76. package/skills/intent-starting/evals/evals.json +2 -2
  77. package/skills/project-continuing/SKILL.md +104 -0
  78. package/skills/project-continuing/evals/evals.json +100 -0
  79. package/skills/project-continuing/references/board-fill.md +33 -0
  80. package/skills/{creating-project → project-creating}/SKILL.md +2 -2
  81. package/skills/releasing/SKILL.md +66 -14
  82. package/skills/releasing/references/release-lines.md +105 -0
  83. package/skills/roadmap/SKILL.md +7 -1
  84. package/skills/roadmap/references/file-format.md +30 -1
  85. package/skills/roadmap/references/operations.md +26 -6
  86. package/skills/roadmap-continuing/SKILL.md +85 -0
  87. package/skills/roadmap-continuing/evals/evals.json +82 -0
  88. package/skills/roadmap-continuing/references/liveness-ranking.md +56 -0
  89. package/skills/{versions → rollback}/SKILL.md +30 -20
  90. package/skills/{creating-skills → skill-creating}/SKILL.md +2 -2
  91. package/skills/{creating-skills → skill-creating}/evals/evals.json +5 -5
  92. package/skills/{creating-skills → skill-creating}/references/progressive-disclosure.md +2 -2
  93. package/skills/{evaluating-skills → skill-evaluating}/SKILL.md +1 -1
  94. package/skills/{evaluating-skills → skill-evaluating}/evals/evals.json +3 -3
  95. package/skills/{intent-curator → store-curating}/SKILL.md +6 -7
  96. package/skills/{intent-curator → store-curating}/evals/evals.json +1 -1
  97. package/skills/{managing-index → store-indexing}/SKILL.md +6 -7
  98. package/skills/{managing-index → store-indexing}/evals/evals.json +1 -1
  99. package/skills/{add-project-store → store-provisioning}/SKILL.md +2 -2
  100. package/skills/tutorial/SKILL.md +65 -0
  101. package/skills/tutorial/evals/evals.json +186 -0
  102. package/skills/tutorial/references/track-1-guided.md +123 -0
  103. package/skills/tutorial/references/track-2-auto.md +95 -0
  104. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +110 -0
  105. package/skills/uninstall/SKILL.md +2 -2
  106. package/skills/update/SKILL.md +3 -3
  107. package/templates/agents.md +7 -7
  108. package/templates/config.yml +2 -1
  109. package/templates/index.md +4 -1
  110. package/skills/brainstorming/evals/evals.json +0 -22
  111. package/skills/savepoint/references/context-management.md +0 -32
  112. /package/skills/{creating-intent → intent-creating}/references/lifecycle.md +0 -0
  113. /package/skills/{creating-intent → intent-creating}/references/wikilinks.md +0 -0
  114. /package/skills/{executing-plan → intent-executing}/code-quality-reviewer-prompt.md +0 -0
  115. /package/skills/{executing-plan → intent-executing}/implementer-prompt.md +0 -0
  116. /package/skills/{executing-plan → intent-executing}/spec-reviewer-prompt.md +0 -0
  117. /package/skills/{linking-intents → intent-linking}/references/zettelkasten.md +0 -0
  118. /package/skills/{continuing → intent-savepoint}/references/context-management.md +0 -0
  119. /package/skills/{creating-project → project-creating}/references/hubs-projects.md +0 -0
  120. /package/skills/{creating-project → project-creating}/references/project-scaffolding.md +0 -0
  121. /package/skills/{creating-skills → skill-creating}/references/agents.md +0 -0
  122. /package/skills/{creating-skills → skill-creating}/references/evals.md +0 -0
  123. /package/skills/{creating-skills → skill-creating}/references/hooks.md +0 -0
  124. /package/skills/{creating-skills → skill-creating}/references/scripts.md +0 -0
  125. /package/skills/{creating-skills → skill-creating}/references/skills.md +0 -0
  126. /package/skills/{creating-skills → skill-creating}/scripts/scaffold.rb +0 -0
  127. /package/skills/{evaluating-skills → skill-evaluating}/assets/eval-template.json +0 -0
  128. /package/skills/{evaluating-skills → skill-evaluating}/references/convention-checks.md +0 -0
  129. /package/skills/{evaluating-skills → skill-evaluating}/references/eval-methodology.md +0 -0
  130. /package/skills/{managing-index → store-indexing}/references/zettelkasten-linking.md +0 -0
@@ -201,7 +201,7 @@ parts = []
201
201
 
202
202
  # Boot banner first: every session start surfaces that Plastic loaded, with the
203
203
  # version (clean) or the first failing core check (degraded). Owned by the hook
204
- # so it runs by construction — the plastic-continuing skill no longer does this.
204
+ # so it runs by construction — the plastic-intent-continuing skill no longer does this.
205
205
  parts << core_banner
206
206
  parts << ""
207
207
 
@@ -168,4 +168,5 @@ class Install < InstallerCore
168
168
  end
169
169
  end
170
170
 
171
- exit(Install.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__
171
+ exit(Install.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__),
172
+ plastic_home: ENV.fetch("PLASTIC_HOME") { InstallerCore::DEFAULT_PLASTIC_HOME }).cli(ARGV)) if $PROGRAM_NAME == __FILE__
@@ -165,6 +165,18 @@ module Bridge
165
165
  -1
166
166
  end
167
167
 
168
+ # The provisioned code worktree dir (<repo>/.claude/worktrees/{id}--{slug})
169
+ # that contains file_path, or nil (intent 168). Structural and bridge-free:
170
+ # Worktree.provision builds exactly this layout, so a file under it is
171
+ # "worktree-scoped" and only that intent may gate it. The {id}--{slug} shape
172
+ # (a `--` in the dir name) is required, so a stray .claude/worktrees/README
173
+ # is not worktree-scoped and returns nil.
174
+ def self.enclosing_worktree_dir(file_path)
175
+ return nil if blank?(file_path)
176
+ m = File.expand_path(file_path).match(%r{\A(.*/\.claude/worktrees/[^/]+--[^/]+)(?:/|\z)})
177
+ m && m[1]
178
+ end
179
+
168
180
  # Resolve the active bridge: scan tmp for plastic-*.json (both per-intent and
169
181
  # legacy-keyed files), keep only valid bridges, filter to the caller's own
170
182
  # session when it has one, prefer auto-armed, then disambiguate by cwd tier
@@ -172,7 +184,7 @@ module Bridge
172
184
  # path: a session now legitimately owns several bridges (one per concurrent
173
185
  # intent), so filename lookup alone cannot pick the right one; cwd must
174
186
  # decide (intent 131).
175
- def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
187
+ def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir, edited_path: nil)
176
188
  candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
177
189
  parsed = candidates.filter_map do |f|
178
190
  data = (JSON.parse(File.read(f)) rescue nil)
@@ -181,6 +193,26 @@ module Bridge
181
193
  end
182
194
  return nil if parsed.empty?
183
195
 
196
+ # Worktree-membership-first (intent 168). hook-code-gate passes the edited
197
+ # file as edited_path; when that file lies inside a provisioned code worktree
198
+ # (<repo>/.claude/worktrees/{id}--{slug}), only that worktree's owning intent
199
+ # may gate the write, BEFORE the intent-90 per-session filter below. Resolve
200
+ # the candidate whose worktree.code owns that dir (newest mtime on a tie), or
201
+ # nil when none owns it, so a session-keyed guided bridge can never claim a
202
+ # write located inside a sibling intent's worktree. No edited_path (every
203
+ # other caller) or a non-worktree path skips this and runs the pipeline
204
+ # unchanged (intents 90/52/131 preserved).
205
+ unless blank?(edited_path)
206
+ wt_dir = enclosing_worktree_dir(edited_path)
207
+ if wt_dir
208
+ owners = parsed.select do |c|
209
+ code = c[:data].dig("worktree", "code")
210
+ !blank?(code) && File.expand_path(code) == wt_dir
211
+ end
212
+ return owners.max_by { |c| c[:mtime] }&.fetch(:data)
213
+ end
214
+ end
215
+
184
216
  has_session = !blank?(session)
185
217
 
186
218
  # Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
@@ -412,7 +444,7 @@ module Bridge
412
444
  NEXT_HINTS = {
413
445
  "why" => "write spec.md",
414
446
  "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
415
- "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
447
+ "exec" => "How complete. Invoke plastic-auto or plastic-intent-executing to execute, or work through the checklist manually.",
416
448
  "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
417
449
  }.freeze
418
450
 
@@ -608,6 +640,75 @@ module Bridge
608
640
  lines.length
609
641
  end
610
642
 
643
+ # --- Phantom-line detection (intent 134) ------------------------------------
644
+ #
645
+ # A companion to the ledger, not a new writer: pure, disk-only, hermetic (no bridge or
646
+ # session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
647
+ # a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
648
+ # can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
649
+ # repair is `rebuild_savepoint` (live intents) or the 124a manual Done-bookend recipe
650
+ # (terminal intents, human-granted only).
651
+
652
+ # (stage, milestone) -> basename, for every file-landing milestone this intent_dir could have
653
+ # produced (the intent file plus the four lifecycle artifacts). Reuses savepoint_milestone so
654
+ # the mapping never drifts from the one the writer itself uses.
655
+ def self.savepoint_file_landing_pairs(intent_dir)
656
+ basenames = [File.basename(intent_file(intent_dir)), "spec.md", "plan.md", "checklist.md", "outcome.md"]
657
+ basenames.each_with_object({}) do |basename, map|
658
+ pair = savepoint_milestone(intent_dir, basename)
659
+ map[pair] = basename if pair
660
+ end
661
+ end
662
+
663
+ # A `started` state line's real prerequisite is the PRECEDING stage's artifact, not its own
664
+ # (a `started` line legitimately fires before its own stage's file is real by design). `Exec
665
+ # started` additionally requires plan.md, since Exec cannot start before How produced it too.
666
+ SAVEPOINT_STATE_PREREQUISITES = {
667
+ ["How", "started"] => ["spec.md"],
668
+ ["Exec", "started"] => ["plan.md", "checklist.md"],
669
+ }.freeze
670
+
671
+ # Raw (stripped) ledger lines whose disk evidence contradicts them, each paired with a short
672
+ # reason: [line, reason]. Three phantom classes (D5):
673
+ # - a file-landing milestone whose file is absent or still a sentinel placeholder;
674
+ # - a duplicate (stage, milestone) pair (the later occurrence is the phantom);
675
+ # - a state line (`How started` / `Exec started`) whose stage prerequisites are absent.
676
+ # A clean ledger, or an absent one, returns [].
677
+ def self.savepoint_phantom_lines(intent_dir)
678
+ path = File.join(intent_dir, SAVEPOINT_FILE)
679
+ return [] unless File.exist?(path)
680
+
681
+ landing = savepoint_file_landing_pairs(intent_dir)
682
+ seen = []
683
+ phantoms = []
684
+
685
+ File.read(path).each_line do |raw|
686
+ line = raw.strip
687
+ next if line.empty?
688
+ parts = line.split(/\s{2,}/)
689
+ next if parts.length < 3
690
+ pair = [parts[1], parts[2]]
691
+
692
+ if seen.include?(pair)
693
+ phantoms << [line, "duplicate (stage, milestone) pair"]
694
+ next
695
+ end
696
+ seen << pair
697
+
698
+ if (basename = landing[pair]) && !stage_file_present?(File.join(intent_dir, basename))
699
+ phantoms << [line, "milestone file absent or still a sentinel placeholder"]
700
+ next
701
+ end
702
+
703
+ prereqs = SAVEPOINT_STATE_PREREQUISITES[pair]
704
+ if prereqs && prereqs.any? { |b| !stage_file_present?(File.join(intent_dir, b)) }
705
+ phantoms << [line, "state line prerequisite absent on disk"]
706
+ end
707
+ end
708
+
709
+ phantoms
710
+ end
711
+
611
712
  def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
612
713
  stage = derive_stage(intent_dir)
613
714
  has = has_files(intent_dir)
@@ -881,6 +982,18 @@ module Bridge
881
982
  name: name, tmp: tmp)
882
983
  data["build"]["auto"] = auto
883
984
  data["lock"] = lock_cache(lock_data)
985
+
986
+ # Provision the per-intent worktrees so the rebuilt bridge carries
987
+ # worktree.code (intent 136). Without it, cwd/edited-path selection has no
988
+ # key: the repaired intent loses its own code gate and a concurrent sibling
989
+ # wins the tie-break. Idempotent (reuse dir / reattach branch) and fail-open
990
+ # for non-git / global-only, exactly as `arm` does; never break the repair.
991
+ begin
992
+ Worktree.provision(data)
993
+ rescue => e
994
+ $stderr.puts "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
995
+ end
996
+
884
997
  write(key, data, tmp: tmp)
885
998
  actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
886
999
 
@@ -918,7 +1031,7 @@ module Bridge
918
1031
 
919
1032
  id = intent_info["id"]
920
1033
  "intent #{id} has not reached How — write plan.md + checklist.md before " \
921
- "editing project code. Run plastic-auto or plastic-writing-plans first. " \
1034
+ "editing project code. Run plastic-auto or plastic-intent-planning first. " \
922
1035
  "(blocked edit: #{file_abs})"
923
1036
  end
924
1037
 
@@ -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
@@ -10,9 +10,9 @@ require_relative "hook_registry"
10
10
  require_relative "agent_models"
11
11
 
12
12
  # Shared installer machinery, instantiable with injected package root / store / agent
13
- # map so the verb scripts (install/update/uninstall/versions) and their tests can run
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 + versions ---
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",
@@ -286,7 +292,8 @@ class InstallerCore
286
292
  "scripts/install.rb" => "scripts/install.rb",
287
293
  "scripts/update.rb" => "scripts/update.rb",
288
294
  "scripts/uninstall.rb" => "scripts/uninstall.rb",
289
- "scripts/versions.rb" => "scripts/versions.rb",
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",
292
299
  }
@@ -471,8 +478,10 @@ class InstallerCore
471
478
  end
472
479
 
473
480
  # Copy each skills/<name>/ to <skills_root>/plastic-<name>/ (flat, namespaced by
474
- # directory name the only personal-skill namespacing Claude Code supports).
475
- # The non-skill `_active-intent-gate.md` is relocated to ~/.plastic/ instead.
481
+ # directory name -- the only personal-skill namespacing Claude Code supports).
482
+ # Any top-level underscore-prefixed markdown fragment (e.g. `_active-intent-gate.md`,
483
+ # `_decision-tables.md`) is a shared non-skill fragment and relocates to ~/.plastic/
484
+ # instead, so every skill can read it from one shared location.
476
485
  def install_skills_flat(skills_source, skills_root)
477
486
  installed = []
478
487
  FileUtils.mkdir_p(skills_root)
@@ -481,7 +490,7 @@ class InstallerCore
481
490
  src = File.join(skills_source, entry)
482
491
  if File.directory?(src)
483
492
  installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"))
484
- elsif entry == "_active-intent-gate.md"
493
+ elsif entry.start_with?("_") && entry.end_with?(".md")
485
494
  FileUtils.mkdir_p(plastic_home)
486
495
  dest = File.join(plastic_home, entry)
487
496
  FileUtils.cp(src, dest)
@@ -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