@zalom/plastic 1.1.0 → 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.md +4 -2
- package/README.md +3 -3
- package/agents/plastic-enforcer.md +5 -2
- package/agents/plastic-future-intent-researcher.md +1 -1
- 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 +136 -5
- package/scripts/hook-continue +3 -3
- package/scripts/install.rb +2 -1
- package/scripts/lib/bridge.rb +81 -0
- package/scripts/lib/dashboard_banner.rb +8 -9
- package/scripts/lib/installer_core.rb +10 -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/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/skills/auto/SKILL.md +22 -4
- 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-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-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
|
@@ -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
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
# RoadmapSavepoint - the roadmap's machine counterpart to its human `## Log` (intent 134).
|
|
8
|
+
#
|
|
9
|
+
# Mirrors scripts/lib/qmd_sync.rb's class shape and the cycle-step savepoint ledger in
|
|
10
|
+
# scripts/lib/bridge.rb (append semantics: idempotent `(event, detail)` pair dedup, one
|
|
11
|
+
# deterministic append primitive). Constructor-DI, hermetic: no eval, no ENV or global config
|
|
12
|
+
# seam, clock injected through `now:`. Two operations:
|
|
13
|
+
#
|
|
14
|
+
# append(roadmap_path, event, detail, now:) - one deterministic, idempotent append
|
|
15
|
+
# rebuild(roadmap_path) - reconstruct the ledger from the roadmap's
|
|
16
|
+
# `## Log` (and, for delivered wave entries with
|
|
17
|
+
# no matching Log line, from INDEX `## Completed`)
|
|
18
|
+
#
|
|
19
|
+
# The ledger is sugar on top of the roadmap file: derived, rebuildable, never a status source.
|
|
20
|
+
# INDEX.md stays the single status writer.
|
|
21
|
+
module RoadmapSavepoint
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
EVENTS = %w[created dispatched parked merged release handoff closed added reordered wave].freeze
|
|
25
|
+
|
|
26
|
+
# Keyword -> event classification for `rebuild`, checked top to bottom, first match wins.
|
|
27
|
+
# Kept small and deterministic (action 1). Order matters: more specific/rarer words are
|
|
28
|
+
# checked before the broader "wave" fallback so an incidental "wave" mention in an otherwise
|
|
29
|
+
# classifiable line never shadows its real event.
|
|
30
|
+
KEYWORD_TABLE = [
|
|
31
|
+
[/\bclosed\b/i, "closed"],
|
|
32
|
+
[/\bhanded off\b|\bhandoff\b/i, "handoff"],
|
|
33
|
+
[/\breleased?\b|\bcut\b|\btagged\b/i, "release"],
|
|
34
|
+
[/\bparked\b|\bblocked\b|\bholds?\b/i, "parked"],
|
|
35
|
+
[/\bdelivered\b|\bmerged\b|\bshipped\b/i, "merged"],
|
|
36
|
+
[/\bdelivering\b|\bdispatch(?:ed)?\b/i, "dispatched"],
|
|
37
|
+
[/\breordered\b/i, "reordered"],
|
|
38
|
+
[/\badded\b|\badds\b/i, "added"],
|
|
39
|
+
[/\bcreated\b/i, "created"],
|
|
40
|
+
[/\bwave\b/i, "wave"],
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# --- append -----------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
# The name-paired sibling ledger for a roadmap path: roadmaps/<slug>.md becomes
|
|
46
|
+
# roadmaps/<slug>.savepoint.md. Resolves correctly for a live roadmap and for one already
|
|
47
|
+
# moved under roadmaps/archived/, because both are plain path substitutions.
|
|
48
|
+
def ledger_path_for(roadmap_path)
|
|
49
|
+
roadmap_path.sub(/\.md\z/, ".savepoint.md")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# `(event, detail)` pairs already recorded in a ledger file, the dedup key (D2): two
|
|
53
|
+
# `dispatched` events with different details are distinct, so the event word alone would be
|
|
54
|
+
# too coarse a key.
|
|
55
|
+
def recorded_pairs(ledger_path)
|
|
56
|
+
return [] unless File.exist?(ledger_path)
|
|
57
|
+
File.read(ledger_path).each_line.filter_map { |line| parse_pair(line) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_pair(line)
|
|
61
|
+
parts = line.strip.split(/\s{2,}/)
|
|
62
|
+
parts.length >= 3 ? [parts[1], parts[2]] : nil
|
|
63
|
+
end
|
|
64
|
+
private_class_method :parse_pair
|
|
65
|
+
|
|
66
|
+
# Append one ledger line "<iso8601> <event> <detail>" unless `(event, detail)` is already
|
|
67
|
+
# recorded (no-op). Creates the paired ledger file (and its directory) lazily. Raises
|
|
68
|
+
# ArgumentError when `event` is outside the controlled vocabulary. Returns true when a line
|
|
69
|
+
# was written, false on a dedup no-op.
|
|
70
|
+
def append(roadmap_path, event, detail, now: Time.now)
|
|
71
|
+
unless EVENTS.include?(event)
|
|
72
|
+
raise ArgumentError, "event must be one of #{EVENTS.join(', ')}, got #{event.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
ledger_path = ledger_path_for(roadmap_path)
|
|
76
|
+
return false if recorded_pairs(ledger_path).include?([event, detail])
|
|
77
|
+
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(ledger_path))
|
|
79
|
+
File.open(ledger_path, "a") { |io| io.write(format_line(now, event, detail)) }
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def format_line(time, event, detail)
|
|
84
|
+
"#{time.utc.iso8601} #{event} #{detail.to_s.strip}\n"
|
|
85
|
+
end
|
|
86
|
+
private_class_method :format_line
|
|
87
|
+
|
|
88
|
+
# --- rebuild ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
# Reconstruct the paired ledger deterministically from the roadmap file's `## Log` (never the
|
|
91
|
+
# roadmap `.md`, which is read-only here), cross-checked against `## Waves` and the tier's
|
|
92
|
+
# INDEX so every `delivered` wave entry has a `merged` line. Every timestamp comes from an
|
|
93
|
+
# on-disk source (the Log, or INDEX `## Completed`); an entry with no recoverable timestamp is
|
|
94
|
+
# not emitted (D4, never invented). Overwrites the ledger (the one operation allowed to rewrite
|
|
95
|
+
# it, matching `Bridge.rebuild_savepoint`). Returns the number of lines written.
|
|
96
|
+
def rebuild(roadmap_path)
|
|
97
|
+
text = File.read(roadmap_path)
|
|
98
|
+
log_lines = classify_log(section_body(text, "Log"))
|
|
99
|
+
delivered_ids = delivered_wave_ids(section_body(text, "Waves"))
|
|
100
|
+
backfilled = backfill_merged_lines(log_lines, delivered_ids, roadmap_path)
|
|
101
|
+
|
|
102
|
+
lines = dedup_pairs(log_lines + backfilled)
|
|
103
|
+
File.write(ledger_path_for(roadmap_path), lines.map { |t, e, d| format_line(t, e, d) }.join)
|
|
104
|
+
lines.length
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Every `- YYYY-MM-DD HH:MM UTC ...` line in the `## Log` body, classified into
|
|
108
|
+
# [time, event, detail]. A line with no keyword match is dropped (no event to record); a
|
|
109
|
+
# continuation line (no date prefix) never matches the header pattern, so it is inherently
|
|
110
|
+
# ignored for classification, per action 1.
|
|
111
|
+
def classify_log(log_body)
|
|
112
|
+
log_body.each_line.filter_map { |line| classify_log_line(line) }
|
|
113
|
+
end
|
|
114
|
+
private_class_method :classify_log
|
|
115
|
+
|
|
116
|
+
LOG_LINE = /\A-\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})\s+UTC\s+(.*)\z/.freeze
|
|
117
|
+
|
|
118
|
+
def classify_log_line(line)
|
|
119
|
+
m = line.strip.match(LOG_LINE)
|
|
120
|
+
return nil unless m
|
|
121
|
+
event = classify_event(m[3])
|
|
122
|
+
return nil unless event
|
|
123
|
+
[parse_log_time(m[1], m[2]), event, m[3].strip]
|
|
124
|
+
end
|
|
125
|
+
private_class_method :classify_log_line
|
|
126
|
+
|
|
127
|
+
def parse_log_time(date, hhmm)
|
|
128
|
+
y, mo, d = date.split("-").map(&:to_i)
|
|
129
|
+
h, mi = hhmm.split(":").map(&:to_i)
|
|
130
|
+
Time.utc(y, mo, d, h, mi, 0)
|
|
131
|
+
end
|
|
132
|
+
private_class_method :parse_log_time
|
|
133
|
+
|
|
134
|
+
def classify_event(text)
|
|
135
|
+
hit = KEYWORD_TABLE.find { |regex, _event| text =~ regex }
|
|
136
|
+
hit && hit[1]
|
|
137
|
+
end
|
|
138
|
+
private_class_method :classify_event
|
|
139
|
+
|
|
140
|
+
WAVE_ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+.+—\s*(\S+)\s*\z/.freeze
|
|
141
|
+
|
|
142
|
+
# Intent ids of every `[x] ... — delivered` entry in the `## Waves` body.
|
|
143
|
+
def delivered_wave_ids(waves_body)
|
|
144
|
+
waves_body.each_line.filter_map do |line|
|
|
145
|
+
m = line.strip.match(WAVE_ENTRY)
|
|
146
|
+
next nil unless m
|
|
147
|
+
checked = m[1].strip.downcase == "x"
|
|
148
|
+
status = m[3].strip.downcase
|
|
149
|
+
m[2] if checked && status == "delivered"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
private_class_method :delivered_wave_ids
|
|
153
|
+
|
|
154
|
+
# For every delivered wave id with no `merged` line already among `log_lines` (the "matching
|
|
155
|
+
# Log line" source, D4), fall back to the tier's INDEX `## Completed` section (the second
|
|
156
|
+
# on-disk source D4 allows). No source at all -> the id is silently skipped, never invented.
|
|
157
|
+
# Backfilled lines are appended after the Log-derived pass (a reconciliation pass run after
|
|
158
|
+
# reconstruction), each carrying its own real on-disk-sourced timestamp even when that
|
|
159
|
+
# timestamp sorts earlier than same-day Log lines above it (INDEX only carries a date, so the
|
|
160
|
+
# time floors to midnight UTC rather than inventing a time-of-day).
|
|
161
|
+
def backfill_merged_lines(log_lines, delivered_ids, roadmap_path)
|
|
162
|
+
index_path = index_path_for(roadmap_path)
|
|
163
|
+
delivered_ids.filter_map do |id|
|
|
164
|
+
next nil if log_lines.any? { |_t, event, detail| event == "merged" && detail =~ /\b#{Regexp.escape(id)}\b/ }
|
|
165
|
+
date = index_completed_date(index_path, id)
|
|
166
|
+
next nil unless date
|
|
167
|
+
[Time.utc(*date.split("-").map(&:to_i)), "merged", "#{id} (from INDEX Completed)"]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
private_class_method :backfill_merged_lines
|
|
171
|
+
|
|
172
|
+
# The tier root is the parent of `roadmaps/` (a live roadmap's grandparent, or, for one
|
|
173
|
+
# already moved to `roadmaps/archived/`, its great-grandparent); INDEX.md is that root's
|
|
174
|
+
# sibling file, matching the layout `plastic-roadmap`'s file-format doc defines.
|
|
175
|
+
def index_path_for(roadmap_path)
|
|
176
|
+
dir = File.dirname(roadmap_path)
|
|
177
|
+
dir = File.dirname(dir) if File.basename(dir) == "archived"
|
|
178
|
+
File.join(File.dirname(dir), "INDEX.md")
|
|
179
|
+
end
|
|
180
|
+
private_class_method :index_path_for
|
|
181
|
+
|
|
182
|
+
def index_completed_date(index_path, id)
|
|
183
|
+
return nil unless index_path && File.exist?(index_path)
|
|
184
|
+
section_body(File.read(index_path), "Completed").each_line do |line|
|
|
185
|
+
next unless line.strip.start_with?("- [#{id} ")
|
|
186
|
+
m = line.match(/\)\s*—\s*(\d{4}-\d{2}-\d{2})\b/)
|
|
187
|
+
return m[1] if m
|
|
188
|
+
end
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
private_class_method :index_completed_date
|
|
192
|
+
|
|
193
|
+
# The body text of a `## <heading>` section: everything after the heading line up to (but not
|
|
194
|
+
# including) the next `## ` heading or end of file.
|
|
195
|
+
def section_body(text, heading)
|
|
196
|
+
m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
|
|
197
|
+
m ? m[1] : ""
|
|
198
|
+
end
|
|
199
|
+
private_class_method :section_body
|
|
200
|
+
|
|
201
|
+
# Stable dedup on the `(event, detail)` pair, keeping the first occurrence in the given
|
|
202
|
+
# (already chronological-then-backfill-appended) order.
|
|
203
|
+
def dedup_pairs(lines)
|
|
204
|
+
seen = []
|
|
205
|
+
lines.select do |_t, event, detail|
|
|
206
|
+
pair = [event, detail]
|
|
207
|
+
next false if seen.include?(pair)
|
|
208
|
+
seen << pair
|
|
209
|
+
true
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
private_class_method :dedup_pairs
|
|
213
|
+
end
|
package/scripts/lib/worktree.rb
CHANGED
|
@@ -79,6 +79,21 @@ module Worktree
|
|
|
79
79
|
}
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# Derive the OS-HOME level (the PARENT of `.plastic`) from an intent store
|
|
83
|
+
# path, anchored on the `.plastic` path segment (Defect 1, intent 169): a
|
|
84
|
+
# store is always `<plastic_home>/store` or
|
|
85
|
+
# `<plastic_home>/projects/<slug>/store`, and `<plastic_home>` is always the
|
|
86
|
+
# `.plastic` dir. Returns nil when `store` is blank or carries no `.plastic`
|
|
87
|
+
# segment, so callers fall back to their own `home:` default rather than
|
|
88
|
+
# resolving anything. Pure: no `ENV[...]` read, no I/O.
|
|
89
|
+
def home_from_store(store)
|
|
90
|
+
return nil if blank?(store)
|
|
91
|
+
parts = File.expand_path(store.to_s).split(File::SEPARATOR)
|
|
92
|
+
idx = parts.rindex(".plastic")
|
|
93
|
+
return nil unless idx
|
|
94
|
+
parts[0...idx].join(File::SEPARATOR)
|
|
95
|
+
end
|
|
96
|
+
|
|
82
97
|
# Absolute repo path for a project slug from `~/.plastic/projects.yml`, or nil.
|
|
83
98
|
# Reuses the qmd_sync safe-loader pattern: any failure yields nil.
|
|
84
99
|
def repo_for(slug, home: Dir.home)
|
|
@@ -106,6 +121,12 @@ module Worktree
|
|
|
106
121
|
store = intent["store"].to_s
|
|
107
122
|
intent_slug = slug_from_dir(intent["dir"]) || slug_from_dir(store)
|
|
108
123
|
|
|
124
|
+
# Defect 1 fix (intent 169): derive plastic_home from the already-sandboxed
|
|
125
|
+
# store path when possible, so a sandboxed board never falls to the real
|
|
126
|
+
# `Dir.home` default. `home:` remains the fallback only when the store is
|
|
127
|
+
# blank or carries no recognizable `.plastic` segment.
|
|
128
|
+
home = home_from_store(store) || home
|
|
129
|
+
|
|
109
130
|
slug = slug_for_store(store, home: home)
|
|
110
131
|
p = paths(slug: slug, intent_id: intent_id, intent_slug: intent_slug, home: home)
|
|
111
132
|
|
package/scripts/new-intent
CHANGED
|
@@ -282,6 +282,7 @@ def main(argv)
|
|
|
282
282
|
|
|
283
283
|
# 2. Create dirs.
|
|
284
284
|
FileUtils.mkdir_p(File.join(intent_dir, "actions"))
|
|
285
|
+
File.write(File.join(intent_dir, "actions", ".gitkeep"), "")
|
|
285
286
|
FileUtils.mkdir_p(File.join(intent_dir, "resources"))
|
|
286
287
|
|
|
287
288
|
# 3. Render the born-complete intent file from templates/intent.md.
|
package/scripts/read-config
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# Returns scalar values as strings, hash/array values as JSON.
|
|
6
6
|
#
|
|
7
7
|
# Environment:
|
|
8
|
-
#
|
|
8
|
+
# PLASTIC_HOME -- override ~/.plastic (for testing)
|
|
9
9
|
|
|
10
10
|
require "yaml"
|
|
11
11
|
require "json"
|
|
@@ -88,7 +88,7 @@ end
|
|
|
88
88
|
|
|
89
89
|
# Handle --migrate mode
|
|
90
90
|
if ARGV.include?("--migrate")
|
|
91
|
-
global_root = ENV.fetch("
|
|
91
|
+
global_root = ENV.fetch("PLASTIC_HOME", File.expand_path("~/.plastic"))
|
|
92
92
|
config_path = File.join(global_root, "config.yml")
|
|
93
93
|
config = load_yaml(config_path)
|
|
94
94
|
|
|
@@ -116,7 +116,7 @@ if key.nil? || key.empty?
|
|
|
116
116
|
exit 1
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
global_root = ENV.fetch("
|
|
119
|
+
global_root = ENV.fetch("PLASTIC_HOME", File.expand_path("~/.plastic"))
|
|
120
120
|
global_config = load_yaml(File.join(global_root, "config.yml"))
|
|
121
121
|
|
|
122
122
|
project_config = {}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# roadmap-next - thin CLI over RoadmapQueue (intent 148).
|
|
6
|
+
#
|
|
7
|
+
# The auto loop and plastic-roadmap-continuing both call this instead of ranking roadmap
|
|
8
|
+
# liveness in prose. Queue mode (default) is for the auto loop: it breaks a tie
|
|
9
|
+
# deterministically and reports the winner's frontier state. Which mode (--which) is for
|
|
10
|
+
# plastic-roadmap-continuing: it returns tie_candidates so the skill's single ask can resolve
|
|
11
|
+
# a genuine tie.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# roadmap-next --roadmaps-dir <dir> [--index <path>] [--which]
|
|
15
|
+
#
|
|
16
|
+
# Exits 0 on any successful analysis, including state "none" and "exhausted" (valid answers,
|
|
17
|
+
# not errors). Exits non-zero only on a missing required flag.
|
|
18
|
+
|
|
19
|
+
require_relative "lib/roadmap_queue"
|
|
20
|
+
require "json"
|
|
21
|
+
|
|
22
|
+
def opt(args, name)
|
|
23
|
+
(i = args.index(name)) && args[i + 1]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def usage
|
|
27
|
+
warn "Usage: roadmap-next --roadmaps-dir <dir> [--index <path>] [--which]"
|
|
28
|
+
warn " default mode is the auto-loop queue; --which returns tie_candidates for continuing."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
which = !!ARGV.delete("--which")
|
|
32
|
+
roadmaps_dir = opt(ARGV, "--roadmaps-dir")
|
|
33
|
+
index = opt(ARGV, "--index")
|
|
34
|
+
|
|
35
|
+
if roadmaps_dir.nil?
|
|
36
|
+
warn "roadmap-next: pass --roadmaps-dir <dir>"
|
|
37
|
+
usage
|
|
38
|
+
exit 2
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
reader = RoadmapQueue.new(roadmaps_dir: roadmaps_dir, index_path: index)
|
|
42
|
+
payload = which ? reader.which : reader.queue
|
|
43
|
+
puts JSON.generate(payload)
|
|
44
|
+
exit 0
|