@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.
Files changed (54) hide show
  1. package/PLASTIC.md +4 -2
  2. package/README.md +3 -3
  3. package/agents/plastic-enforcer.md +5 -2
  4. package/agents/plastic-future-intent-researcher.md +1 -1
  5. package/hooks/check-update +1 -1
  6. package/hooks/continue +1 -1
  7. package/package.json +1 -1
  8. package/scripts/dashboard.rb +29 -24
  9. package/scripts/doctor.rb +136 -5
  10. package/scripts/hook-continue +3 -3
  11. package/scripts/install.rb +2 -1
  12. package/scripts/lib/bridge.rb +81 -0
  13. package/scripts/lib/dashboard_banner.rb +8 -9
  14. package/scripts/lib/installer_core.rb +10 -3
  15. package/scripts/lib/legacy_bookend_amnesty.rb +35 -0
  16. package/scripts/lib/release_guard.rb +62 -0
  17. package/scripts/lib/roadmap_queue.rb +285 -0
  18. package/scripts/lib/roadmap_savepoint.rb +213 -0
  19. package/scripts/lib/worktree.rb +21 -0
  20. package/scripts/new-intent +1 -0
  21. package/scripts/read-config +3 -3
  22. package/scripts/roadmap-next +44 -0
  23. package/scripts/roadmap-savepoint +64 -0
  24. package/skills/auto/SKILL.md +22 -4
  25. package/skills/continuing/SKILL.md +34 -0
  26. package/skills/continuing/evals/evals.json +91 -0
  27. package/skills/dashboard/SKILL.md +17 -14
  28. package/skills/dashboard/references/classification.md +3 -3
  29. package/skills/dashboard/templates/dashboard-global.md +8 -23
  30. package/skills/dashboard/templates/dashboard-project.md +7 -26
  31. package/skills/doctor/SKILL.md +1 -1
  32. package/skills/install/SKILL.md +10 -10
  33. package/skills/intent-continuing/SKILL.md +26 -68
  34. package/skills/intent-continuing/evals/evals.json +26 -26
  35. package/skills/intent-continuing/references/context-management.md +15 -19
  36. package/skills/intent-savepoint/SKILL.md +12 -0
  37. package/skills/intent-starting/evals/evals.json +1 -1
  38. package/skills/project-continuing/SKILL.md +104 -0
  39. package/skills/project-continuing/evals/evals.json +100 -0
  40. package/skills/project-continuing/references/board-fill.md +33 -0
  41. package/skills/releasing/SKILL.md +48 -0
  42. package/skills/releasing/references/release-lines.md +105 -0
  43. package/skills/roadmap/SKILL.md +7 -1
  44. package/skills/roadmap/references/file-format.md +30 -1
  45. package/skills/roadmap/references/operations.md +26 -6
  46. package/skills/roadmap-continuing/SKILL.md +85 -0
  47. package/skills/roadmap-continuing/evals/evals.json +82 -0
  48. package/skills/roadmap-continuing/references/liveness-ranking.md +56 -0
  49. package/skills/skill-evaluating/evals/evals.json +1 -1
  50. package/skills/tutorial/references/track-2-auto.md +1 -1
  51. package/skills/uninstall/SKILL.md +2 -2
  52. package/skills/update/SKILL.md +2 -2
  53. package/templates/config.yml +2 -1
  54. 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
@@ -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
 
@@ -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.
@@ -5,7 +5,7 @@
5
5
  # Returns scalar values as strings, hash/array values as JSON.
6
6
  #
7
7
  # Environment:
8
- # PLASTIC_GLOBAL_ROOT -- override ~/.plastic (for testing)
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("PLASTIC_GLOBAL_ROOT", File.expand_path("~/.plastic"))
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("PLASTIC_GLOBAL_ROOT", File.expand_path("~/.plastic"))
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