@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.
- package/PLASTIC.md +12 -9
- package/README.md +3 -3
- package/agents/plastic-enforcer.md +8 -4
- package/agents/plastic-executor.md +5 -5
- package/agents/plastic-future-intent-researcher.md +1 -1
- package/agents/plastic-planner.md +15 -11
- package/hooks/check-update +1 -1
- package/hooks/continue +1 -1
- package/package.json +1 -1
- package/scripts/dashboard.rb +29 -24
- package/scripts/doctor.rb +180 -5
- package/scripts/hook-continue +3 -3
- package/scripts/install.rb +2 -1
- package/scripts/lib/bridge.rb +114 -11
- package/scripts/lib/dashboard_banner.rb +8 -9
- package/scripts/lib/installer_core.rb +12 -3
- package/scripts/lib/legacy_bookend_amnesty.rb +35 -0
- package/scripts/lib/release_guard.rb +62 -0
- package/scripts/lib/roadmap_queue.rb +285 -0
- package/scripts/lib/roadmap_savepoint.rb +213 -0
- package/scripts/lib/skill_lint.rb +304 -0
- package/scripts/lib/worktree.rb +21 -0
- package/scripts/new-intent +1 -0
- package/scripts/read-config +3 -3
- package/scripts/roadmap-next +44 -0
- package/scripts/roadmap-savepoint +64 -0
- package/scripts/skill-lint +50 -0
- package/skills/auto/SKILL.md +32 -11
- package/skills/auto/references/tiers.md +4 -3
- package/skills/continuing/SKILL.md +34 -0
- package/skills/continuing/evals/evals.json +91 -0
- package/skills/dashboard/SKILL.md +17 -14
- package/skills/dashboard/references/classification.md +3 -3
- package/skills/dashboard/templates/dashboard-global.md +8 -23
- package/skills/dashboard/templates/dashboard-project.md +7 -26
- package/skills/doctor/SKILL.md +1 -1
- package/skills/install/SKILL.md +10 -10
- package/skills/intent-continuing/SKILL.md +26 -68
- package/skills/intent-continuing/evals/evals.json +26 -26
- package/skills/intent-continuing/references/context-management.md +15 -19
- package/skills/intent-planning/SKILL.md +11 -11
- package/skills/intent-planning/evals/evals.json +20 -5
- package/skills/intent-planning/references/plan-format.md +9 -5
- package/skills/intent-savepoint/SKILL.md +12 -0
- package/skills/intent-starting/evals/evals.json +1 -1
- package/skills/project-continuing/SKILL.md +104 -0
- package/skills/project-continuing/evals/evals.json +100 -0
- package/skills/project-continuing/references/board-fill.md +33 -0
- package/skills/releasing/SKILL.md +48 -0
- package/skills/releasing/references/release-lines.md +105 -0
- package/skills/roadmap/SKILL.md +7 -1
- package/skills/roadmap/references/file-format.md +30 -1
- package/skills/roadmap/references/operations.md +26 -6
- package/skills/roadmap-continuing/SKILL.md +85 -0
- package/skills/roadmap-continuing/evals/evals.json +82 -0
- package/skills/roadmap-continuing/references/liveness-ranking.md +56 -0
- package/skills/skill-evaluating/evals/evals.json +1 -1
- package/skills/tutorial/references/track-1-guided.md +5 -4
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/skills/uninstall/SKILL.md +2 -2
- package/skills/update/SKILL.md +2 -2
- package/templates/config.yml +2 -1
- package/templates/index.md +4 -1
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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")
|
|
714
|
-
return "Cannot complete How — plan.md
|
|
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" =
|
|
940
|
-
#
|
|
941
|
-
#
|
|
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
|
|
953
|
-
"editing project code. Run plastic-auto or
|
|
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
|
|
30
|
-
#
|
|
31
|
-
# already rank-sorted). Returns nil for any other shape rather than
|
|
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
|
-
|
|
34
|
-
return nil unless
|
|
35
|
-
|
|
36
|
-
return nil unless
|
|
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
|
|
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)
|
|
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
|
|
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
|