@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.
- package/PLASTIC-reference.md +11 -11
- package/PLASTIC.md +11 -9
- package/README.md +48 -1
- package/agents/plastic-enforcer.md +8 -5
- package/agents/plastic-future-intent-researcher.md +1 -1
- package/agents/plastic-intent-curator.md +9 -10
- package/agents/plastic-spec-specialist.md +9 -6
- package/bin/plastic.js +2 -2
- 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 +188 -9
- package/scripts/end-intent +330 -0
- package/scripts/hook-code-gate +1 -1
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +2 -2
- package/scripts/hook-session-start +1 -1
- package/scripts/install.rb +2 -1
- package/scripts/lib/bridge.rb +116 -3
- package/scripts/lib/dashboard_banner.rb +8 -9
- package/scripts/lib/installer_core.rb +17 -8
- 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/store_provisioning.rb +1 -1
- package/scripts/lib/worktree.rb +21 -0
- package/scripts/new-intent +2 -1
- package/scripts/read-config +3 -3
- package/scripts/roadmap-next +44 -0
- package/scripts/roadmap-savepoint +64 -0
- package/scripts/{versions.rb → rollback.rb} +33 -41
- package/scripts/update.rb +1 -1
- package/skills/_active-intent-gate.md +1 -1
- package/skills/_decision-tables.md +50 -0
- package/skills/auto/SKILL.md +69 -68
- package/skills/auto/evals/evals.json +4 -4
- package/skills/auto/references/agent-architecture.md +2 -2
- package/skills/continuing/SKILL.md +23 -130
- package/skills/continuing/evals/evals.json +26 -80
- package/skills/dashboard/SKILL.md +18 -15
- 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 +3 -3
- package/skills/install/SKILL.md +10 -10
- package/skills/{brainstorming → intent-brainstorming}/SKILL.md +43 -45
- package/skills/intent-brainstorming/evals/evals.json +67 -0
- package/skills/{brainstorming → intent-brainstorming}/references/design-principles.md +12 -17
- package/skills/intent-continuing/SKILL.md +99 -0
- package/skills/intent-continuing/evals/evals.json +145 -0
- package/skills/intent-continuing/references/context-management.md +28 -0
- package/skills/{creating-intent → intent-creating}/SKILL.md +3 -3
- package/skills/{creating-intent → intent-creating}/evals/evals.json +1 -1
- package/skills/{intent-discovery → intent-discovering}/SKILL.md +1 -1
- package/skills/intent-ending/SKILL.md +151 -0
- package/skills/intent-ending/evals/evals.json +74 -0
- package/skills/{executing-plan → intent-executing}/SKILL.md +75 -15
- package/skills/intent-executing/evals/evals.json +66 -0
- package/skills/{brainstorming-grill-me → intent-grilling}/SKILL.md +4 -4
- package/skills/{linking-intents → intent-linking}/SKILL.md +1 -1
- package/skills/{linking-intents → intent-linking}/evals/evals.json +1 -1
- package/skills/{lock → intent-locking}/SKILL.md +1 -1
- package/skills/{writing-plans → intent-planning}/SKILL.md +48 -20
- package/skills/intent-planning/evals/evals.json +66 -0
- package/skills/{writing-plans → intent-planning}/references/plan-format.md +31 -7
- package/skills/{research → intent-researching}/SKILL.md +2 -2
- package/skills/{research → intent-researching}/evals/evals.json +1 -1
- package/skills/{savepoint → intent-savepoint}/SKILL.md +13 -1
- package/skills/intent-speccing/SKILL.md +69 -0
- package/skills/intent-speccing/evals/evals.json +126 -0
- package/skills/intent-speccing/references/per-section-fill-rules.md +92 -0
- package/skills/intent-speccing/references/self-verify-checklist.md +38 -0
- package/skills/intent-starting/SKILL.md +2 -2
- package/skills/intent-starting/evals/evals.json +2 -2
- 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/{creating-project → project-creating}/SKILL.md +2 -2
- package/skills/releasing/SKILL.md +66 -14
- 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/{versions → rollback}/SKILL.md +30 -20
- package/skills/{creating-skills → skill-creating}/SKILL.md +2 -2
- package/skills/{creating-skills → skill-creating}/evals/evals.json +5 -5
- package/skills/{creating-skills → skill-creating}/references/progressive-disclosure.md +2 -2
- package/skills/{evaluating-skills → skill-evaluating}/SKILL.md +1 -1
- package/skills/{evaluating-skills → skill-evaluating}/evals/evals.json +3 -3
- package/skills/{intent-curator → store-curating}/SKILL.md +6 -7
- package/skills/{intent-curator → store-curating}/evals/evals.json +1 -1
- package/skills/{managing-index → store-indexing}/SKILL.md +6 -7
- package/skills/{managing-index → store-indexing}/evals/evals.json +1 -1
- package/skills/{add-project-store → store-provisioning}/SKILL.md +2 -2
- package/skills/tutorial/SKILL.md +65 -0
- package/skills/tutorial/evals/evals.json +186 -0
- package/skills/tutorial/references/track-1-guided.md +123 -0
- package/skills/tutorial/references/track-2-auto.md +95 -0
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +110 -0
- package/skills/uninstall/SKILL.md +2 -2
- package/skills/update/SKILL.md +3 -3
- package/templates/agents.md +7 -7
- package/templates/config.yml +2 -1
- package/templates/index.md +4 -1
- package/skills/brainstorming/evals/evals.json +0 -22
- package/skills/savepoint/references/context-management.md +0 -32
- /package/skills/{creating-intent → intent-creating}/references/lifecycle.md +0 -0
- /package/skills/{creating-intent → intent-creating}/references/wikilinks.md +0 -0
- /package/skills/{executing-plan → intent-executing}/code-quality-reviewer-prompt.md +0 -0
- /package/skills/{executing-plan → intent-executing}/implementer-prompt.md +0 -0
- /package/skills/{executing-plan → intent-executing}/spec-reviewer-prompt.md +0 -0
- /package/skills/{linking-intents → intent-linking}/references/zettelkasten.md +0 -0
- /package/skills/{continuing → intent-savepoint}/references/context-management.md +0 -0
- /package/skills/{creating-project → project-creating}/references/hubs-projects.md +0 -0
- /package/skills/{creating-project → project-creating}/references/project-scaffolding.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/agents.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/evals.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/hooks.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/scripts.md +0 -0
- /package/skills/{creating-skills → skill-creating}/references/skills.md +0 -0
- /package/skills/{creating-skills → skill-creating}/scripts/scaffold.rb +0 -0
- /package/skills/{evaluating-skills → skill-evaluating}/assets/eval-template.json +0 -0
- /package/skills/{evaluating-skills → skill-evaluating}/references/convention-checks.md +0 -0
- /package/skills/{evaluating-skills → skill-evaluating}/references/eval-methodology.md +0 -0
- /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
|
|
package/scripts/install.rb
CHANGED
|
@@ -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__)
|
|
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__
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
|
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
|
|
@@ -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/
|
|
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",
|
|
@@ -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/
|
|
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
|
|
475
|
-
#
|
|
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
|
|
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
|