@zalom/plastic 1.3.0 → 1.4.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 +8 -6
- package/PLASTIC.md +52 -14
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +393 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +105 -27
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +34 -3
- package/scripts/lib/installer_core.rb +70 -13
- package/scripts/lib/intent_validator.rb +38 -10
- package/scripts/lib/links_gate.rb +140 -0
- package/scripts/lib/links_projection.rb +71 -12
- package/scripts/lib/lock.rb +186 -11
- package/scripts/lib/power_tools.rb +57 -14
- package/scripts/lib/project_validator.rb +113 -0
- package/scripts/lib/qmd_hook.rb +12 -8
- package/scripts/lib/restore_intent_v1.rb +154 -0
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +38 -10
- package/scripts/lib/store_discovery.rb +77 -0
- package/scripts/lib/store_provisioning.rb +21 -12
- package/scripts/new-intent +10 -12
- package/scripts/plastic-lock +76 -9
- package/scripts/project-links +132 -35
- package/scripts/provision-project-store +18 -5
- package/scripts/read-config +1 -0
- package/scripts/rebuild-graph +42 -17
- package/scripts/restore-intent-v1 +288 -0
- package/scripts/roadmap-next +9 -2
- package/scripts/roadmap-savepoint +9 -1
- package/scripts/update.rb +50 -1
- package/scripts/validate-intent +3 -1
- package/scripts/validate-project +53 -0
- package/scripts/write-config +105 -0
- package/skills/auto/SKILL.md +45 -16
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- package/skills/install/SKILL.md +4 -4
- package/skills/intent-creating/SKILL.md +5 -0
- package/skills/intent-ending/SKILL.md +49 -36
- package/skills/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- package/skills/project-creating/SKILL.md +29 -1
- package/skills/releasing/SKILL.md +37 -19
- package/skills/roadmap/SKILL.md +9 -7
- package/skills/roadmap/references/file-format.md +14 -10
- package/skills/roadmap/references/operations.md +22 -18
- package/skills/roadmap-continuing/SKILL.md +5 -5
- package/skills/roadmap-continuing/evals/evals.json +3 -3
- package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
- package/skills/tutorial/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +12 -11
- package/skills/update/SKILL.md +30 -17
- package/templates/roadmap.md +8 -8
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# restore-intent-v1 - restore a completed intent's PROSE to an explicit v1 git ref
|
|
6
|
+
# while preserving its frontmatter GRAPH (sources/chain) as a target-resolved
|
|
7
|
+
# union of the v1 snapshot and the current snapshot (intent 193). This is the
|
|
8
|
+
# ONLY sanctioned way to restore a completed intent to v1; a hand-run whole-file
|
|
9
|
+
# `git checkout`/revert is forbidden (see PLASTIC.md > Terminal immutability),
|
|
10
|
+
# because it cannot tell prose from graph metadata and silently destroys
|
|
11
|
+
# backlinks written after v1 (the 124/131 incident this tool exists to prevent).
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# restore-intent-v1 <intent-id> --at <git-ref> [--plastic-home PATH] [--apply] \
|
|
15
|
+
# [--skip-links] [--audit-path PATH]
|
|
16
|
+
#
|
|
17
|
+
# Dry-run by default (the OPPOSITE of rebuild-graph/project-links, which default
|
|
18
|
+
# to a real run): this tool is rarer and higher blast-radius, and this exact
|
|
19
|
+
# class of tool already destroyed live store data once. --apply is required to
|
|
20
|
+
# write.
|
|
21
|
+
#
|
|
22
|
+
# Restore-to-v1 runs under the MAINTENANCE lock (PLASTIC.md > Terminal
|
|
23
|
+
# immutability: the maintenance lock covers sanctioned structural move-and-record
|
|
24
|
+
# edits after completion). This tool does NOT acquire, check, or manage that
|
|
25
|
+
# lock itself (fail-open doctrine, intent 111: lock management is the
|
|
26
|
+
# orchestrator's job, never built into a CLI as a trap); it only prints a
|
|
27
|
+
# one-line reminder on --apply.
|
|
28
|
+
#
|
|
29
|
+
# LINKS BLAST RADIUS: an applied restore reprojects `## Links` via
|
|
30
|
+
# scripts/project-links, which is a STORE-WIDE operation with no per-intent
|
|
31
|
+
# scoping (it rewrites `## Links` in every intent under --plastic-home, not only
|
|
32
|
+
# the one being restored). This is announced explicitly before it runs. Pass
|
|
33
|
+
# --skip-links to decline it (the tool then warns loudly that `## Links` is
|
|
34
|
+
# stale until project-links is run by hand).
|
|
35
|
+
#
|
|
36
|
+
# Pure-Ruby (no bash). Graph math lives in lib/restore_intent_v1.rb and reuses
|
|
37
|
+
# lib/graph_rebuild.rb + lib/frontmatter_writer.rb verbatim; this shell does only
|
|
38
|
+
# discovery, git IO, and reporting. Never pushes ~/.plastic.
|
|
39
|
+
|
|
40
|
+
require "fileutils"
|
|
41
|
+
require "time"
|
|
42
|
+
require "open3"
|
|
43
|
+
|
|
44
|
+
require_relative "lib/store_discovery"
|
|
45
|
+
require_relative "lib/intent_validator"
|
|
46
|
+
require_relative "lib/graph_rebuild"
|
|
47
|
+
require_relative "lib/frontmatter_writer"
|
|
48
|
+
require_relative "lib/restore_intent_v1"
|
|
49
|
+
|
|
50
|
+
class RestoreIntentV1CLI
|
|
51
|
+
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
52
|
+
PROSE_SIBLINGS = %w[checklist.md outcome.md spec.md plan.md].freeze
|
|
53
|
+
|
|
54
|
+
def self.parse_argv(argv)
|
|
55
|
+
args = argv.dup
|
|
56
|
+
intent_id = nil
|
|
57
|
+
opts = { at: nil, plastic_home: DEFAULT_HOME, apply: false, skip_links: false, audit_path: nil }
|
|
58
|
+
i = 0
|
|
59
|
+
while i < args.length
|
|
60
|
+
case args[i]
|
|
61
|
+
when "--at" then opts[:at] = args[i += 1]
|
|
62
|
+
when "--plastic-home" then opts[:plastic_home] = args[i += 1]
|
|
63
|
+
when "--apply" then opts[:apply] = true
|
|
64
|
+
when "--skip-links" then opts[:skip_links] = true
|
|
65
|
+
when "--audit-path" then opts[:audit_path] = args[i += 1]
|
|
66
|
+
else
|
|
67
|
+
intent_id ||= args[i]
|
|
68
|
+
end
|
|
69
|
+
i += 1
|
|
70
|
+
end
|
|
71
|
+
[intent_id, opts]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(intent_id, at:, plastic_home: DEFAULT_HOME, apply: false, skip_links: false, audit_path: nil)
|
|
75
|
+
@intent_id = intent_id
|
|
76
|
+
@at = at
|
|
77
|
+
@plastic_home = plastic_home
|
|
78
|
+
@apply = apply
|
|
79
|
+
@skip_links = skip_links
|
|
80
|
+
@audit_path = audit_path
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
attr_reader :intent_id, :at, :plastic_home, :apply, :skip_links, :audit_path
|
|
84
|
+
|
|
85
|
+
def run
|
|
86
|
+
abort_loud("--at is required") if at.nil? || at.to_s.strip.empty?
|
|
87
|
+
abort_loud("intent id is required") if intent_id.nil? || intent_id.to_s.strip.empty?
|
|
88
|
+
abort_loud("--at #{at} does not resolve to a commit") unless ref_exists?
|
|
89
|
+
|
|
90
|
+
discovery = StoreDiscovery.discover(plastic_home)
|
|
91
|
+
dir, store_key = find_intent_dir(discovery, intent_id)
|
|
92
|
+
abort_loud("intent #{intent_id} not found in any known store under #{plastic_home}") if dir.nil?
|
|
93
|
+
|
|
94
|
+
base = File.basename(dir)
|
|
95
|
+
md_path = File.join(dir, "#{base}.md")
|
|
96
|
+
|
|
97
|
+
v1_md = git_show(relative(md_path))
|
|
98
|
+
abort_loud("--at #{at} has no version of #{base}.md; aborting, no write") if v1_md.nil?
|
|
99
|
+
|
|
100
|
+
current_md = File.read(md_path)
|
|
101
|
+
current_fm = IntentValidator.parse_frontmatter_text(current_md)
|
|
102
|
+
v1_fm = IntentValidator.parse_frontmatter_text(v1_md)
|
|
103
|
+
abort_loud("v1 snapshot frontmatter unparseable; aborting, no write") if v1_fm.nil? || v1_fm.empty?
|
|
104
|
+
abort_loud("current frontmatter unparseable; aborting, no write") if current_fm.nil? || current_fm.empty?
|
|
105
|
+
|
|
106
|
+
store_index, relocation_map = build_resolution_inputs(discovery)
|
|
107
|
+
|
|
108
|
+
graph = RestoreIntentV1.compute_graph(
|
|
109
|
+
v1_sources: v1_fm["sources"], v1_chain: v1_fm["chain"],
|
|
110
|
+
current_sources: current_fm["sources"], current_chain: current_fm["chain"],
|
|
111
|
+
referer_store: store_key, relocation_map: relocation_map, store_index: store_index
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
new_md = RestoreIntentV1.apply_graph(v1_md, desired_sources: graph[:sources], desired_chain: graph[:chain])
|
|
115
|
+
confirm_graph_write!(new_md, graph)
|
|
116
|
+
|
|
117
|
+
other_files = prose_siblings_to_restore(dir)
|
|
118
|
+
|
|
119
|
+
prose_changes = other_files.keys.dup
|
|
120
|
+
prose_changes.unshift("#{base}.md") if new_md != current_md
|
|
121
|
+
|
|
122
|
+
puts RestoreIntentV1.render_report(
|
|
123
|
+
base: base, at: at, prose_changes: prose_changes,
|
|
124
|
+
v1: { sources: v1_fm["sources"], chain: v1_fm["chain"] },
|
|
125
|
+
current: { sources: current_fm["sources"], chain: current_fm["chain"] },
|
|
126
|
+
graph: graph, apply: apply
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return unless apply
|
|
130
|
+
|
|
131
|
+
File.write(md_path, new_md) if new_md != current_md
|
|
132
|
+
other_files.each { |f, content| File.write(File.join(dir, f), content) }
|
|
133
|
+
|
|
134
|
+
puts "Reminder: restore-to-v1 runs under the maintenance lock (PLASTIC.md > Terminal " \
|
|
135
|
+
"immutability). Confirm the maintenance lock is held before this --apply."
|
|
136
|
+
|
|
137
|
+
handle_links_reprojection(base)
|
|
138
|
+
append_revision(dir, base, graph, files: prose_changes,
|
|
139
|
+
before_sources: current_fm["sources"], before_chain: current_fm["chain"])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# D8 fail-loud, "unconfirmed graph write": re-parse what was actually computed
|
|
145
|
+
# for write and assert its sources/chain arrays EQUAL the computed union.
|
|
146
|
+
# Asserting the substrings "sources:"/"chain:" merely appear somewhere in the
|
|
147
|
+
# text is NOT a confirmation (a no-op rewrite on a v1 snapshot missing those
|
|
148
|
+
# keys entirely would still contain neither substring's absence, this checks
|
|
149
|
+
# the actual parsed values match). Aborts loud, no write, when they do not.
|
|
150
|
+
def confirm_graph_write!(new_md, graph)
|
|
151
|
+
written_fm = IntentValidator.parse_frontmatter_text(new_md)
|
|
152
|
+
written_sources = written_fm ? Array(written_fm["sources"]).map(&:to_s) : nil
|
|
153
|
+
written_chain = written_fm ? Array(written_fm["chain"]).map(&:to_s) : nil
|
|
154
|
+
|
|
155
|
+
return if written_fm && written_sources == graph[:sources] && written_chain == graph[:chain]
|
|
156
|
+
|
|
157
|
+
abort_loud(
|
|
158
|
+
"the rewritten frontmatter does not confirm the computed sources/chain union " \
|
|
159
|
+
"(expected sources=#{graph[:sources].inspect} chain=#{graph[:chain].inspect}, " \
|
|
160
|
+
"got sources=#{written_sources.inspect} chain=#{written_chain.inspect}); aborting, no write"
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Every PROSE_SIBLING that existed AT THE V1 REF (not merely that exists NOW)
|
|
165
|
+
# whose v1 content differs from what is on disk today. Scoping to "exists now"
|
|
166
|
+
# would silently never restore a sibling deleted after v1 (BLOCKING bug): the
|
|
167
|
+
# exact class of silent loss this intent exists to prevent, just on a prose
|
|
168
|
+
# file instead of a graph edge. A sibling deleted since v1 is recreated with
|
|
169
|
+
# its exact v1 bytes; a sibling that never existed at v1 is left untouched.
|
|
170
|
+
def prose_siblings_to_restore(dir)
|
|
171
|
+
other_files = {}
|
|
172
|
+
PROSE_SIBLINGS.each do |f|
|
|
173
|
+
path = File.join(dir, f)
|
|
174
|
+
v1_content = git_show(relative(path))
|
|
175
|
+
next if v1_content.nil? # did not exist at v1: nothing to restore
|
|
176
|
+
|
|
177
|
+
current_content = File.exist?(path) ? File.read(path) : nil
|
|
178
|
+
other_files[f] = v1_content if v1_content != current_content
|
|
179
|
+
end
|
|
180
|
+
other_files
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# LINKS BLAST RADIUS (D8/Goal 4): reprojection is store-wide, not scoped to
|
|
184
|
+
# this intent. Announce it unmissably before running it, unless --skip-links
|
|
185
|
+
# was given, in which case warn loudly that ## Links is now stale.
|
|
186
|
+
def handle_links_reprojection(base)
|
|
187
|
+
if skip_links
|
|
188
|
+
warn "restore-intent-v1: --skip-links given; ## Links across the WHOLE STORE " \
|
|
189
|
+
"(#{plastic_home}) is now potentially STALE relative to the preserved graph. " \
|
|
190
|
+
"Rerun `ruby #{File.expand_path("project-links", __dir__)} --plastic-home " \
|
|
191
|
+
"#{plastic_home}` to reproject it."
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
puts "restore-intent-v1: reprojecting ## Links STORE-WIDE (every intent under " \
|
|
196
|
+
"#{plastic_home}, not only #{base}) via project-links..."
|
|
197
|
+
reproject_links
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def ref_exists?
|
|
201
|
+
_out, status = Open3.capture2("git", "-C", plastic_home, "rev-parse", "--verify", "--quiet", "#{at}^{commit}")
|
|
202
|
+
status.success?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def git_show(rel_path)
|
|
206
|
+
out, status = Open3.capture2("git", "-C", plastic_home, "show", "#{at}:#{rel_path}")
|
|
207
|
+
status.success? ? out : nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def relative(path)
|
|
211
|
+
path.delete_prefix("#{plastic_home}/")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# id--slug directory resolution: exact id match on the dirname's segment before
|
|
215
|
+
# the first "--" (matches the convention scripts/doctor.rb already uses), never
|
|
216
|
+
# a start_with? scan, so "1" never matches "124--...". A bare id present in MORE
|
|
217
|
+
# THAN ONE store is ambiguous; given this tool's blast radius, abort loud and
|
|
218
|
+
# name every candidate rather than silently guessing the first one found.
|
|
219
|
+
def find_intent_dir(discovery, id)
|
|
220
|
+
matches = []
|
|
221
|
+
discovery[:stores].each do |s|
|
|
222
|
+
Dir.children(s[:store]).reject { |e| e.start_with?(".") }.each do |entry|
|
|
223
|
+
full = File.join(s[:store], entry)
|
|
224
|
+
next unless File.directory?(full)
|
|
225
|
+
next unless entry.split("--", 2).first == id.to_s
|
|
226
|
+
|
|
227
|
+
matches << [full, s[:key]]
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
return matches.first if matches.length <= 1
|
|
231
|
+
|
|
232
|
+
candidates = matches.map { |(full, key)| "#{key}:#{File.basename(full)}" }.join(", ")
|
|
233
|
+
abort_loud(
|
|
234
|
+
"intent id #{id} is ambiguous: found in more than one store (#{candidates}); " \
|
|
235
|
+
"aborting, no write. This tool has too much blast radius to guess which one you " \
|
|
236
|
+
"mean; resolve the collision (rename or relocate one of the intents) before restoring."
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Build the store_index (store_key => bare ids present) and relocation_map
|
|
241
|
+
# (from every store's INDEX.md ## Relocated log) that GraphRebuild.resolve_ref
|
|
242
|
+
# needs, using the exact recipe scripts/rebuild-graph already uses.
|
|
243
|
+
def build_resolution_inputs(discovery)
|
|
244
|
+
store_index = {}
|
|
245
|
+
index_texts = {}
|
|
246
|
+
discovery[:stores].each do |s|
|
|
247
|
+
store_index[s[:key]] = Dir.children(s[:store]).reject { |e| e.start_with?(".") }
|
|
248
|
+
.select { |e| File.directory?(File.join(s[:store], e)) }
|
|
249
|
+
.map { |e| e.split("--", 2).first }
|
|
250
|
+
index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
|
|
251
|
+
end
|
|
252
|
+
[store_index, GraphRebuild.build_relocation_map(index_texts)]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def reproject_links
|
|
256
|
+
project_links = File.expand_path("project-links", __dir__)
|
|
257
|
+
system(RbConfig.ruby, project_links, "--plastic-home", plastic_home)
|
|
258
|
+
return if $?.success?
|
|
259
|
+
|
|
260
|
+
warn "restore-intent-v1: ## Links may now be stale (project-links reprojection failed). " \
|
|
261
|
+
"Rerun: ruby #{project_links} --plastic-home #{plastic_home}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def append_revision(dir, base, graph, files:, before_sources:, before_chain:)
|
|
265
|
+
path = File.join(dir, "revisions.md")
|
|
266
|
+
existing = File.exist?(path) ? File.read(path) : "# revisions.md\n\n"
|
|
267
|
+
nums = existing.scan(/^## Revision v(\d+)/).flatten.map(&:to_i)
|
|
268
|
+
n = (nums.max || 0) + 1
|
|
269
|
+
|
|
270
|
+
entry = RestoreIntentV1.render_revision_entry(
|
|
271
|
+
n, at: at, timestamp: Time.now.utc.strftime("%Y-%m-%d-%H:%M"), files: files,
|
|
272
|
+
before_sources: before_sources, after_sources: graph[:sources],
|
|
273
|
+
before_chain: before_chain, after_chain: graph[:chain],
|
|
274
|
+
dropped: graph[:dropped]
|
|
275
|
+
)
|
|
276
|
+
File.write(path, "#{existing.chomp}\n\n#{entry}")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def abort_loud(message)
|
|
280
|
+
warn "restore-intent-v1: #{message}"
|
|
281
|
+
exit 1
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
if $PROGRAM_NAME == __FILE__
|
|
286
|
+
intent_id, opts = RestoreIntentV1CLI.parse_argv(ARGV)
|
|
287
|
+
RestoreIntentV1CLI.new(intent_id, **opts).run
|
|
288
|
+
end
|
package/scripts/roadmap-next
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
# roadmap-next --roadmaps-dir <dir> [--index <path>] [--which]
|
|
15
15
|
#
|
|
16
16
|
# Exits 0 on any successful analysis, including state "none" and "exhausted" (valid answers,
|
|
17
|
-
# not errors). Exits
|
|
17
|
+
# not errors). Exits 2 on a missing required flag. Exits 3 when a roadmap has neither
|
|
18
|
+
# '## Batches' (canonical) nor '## Waves' (legacy) as its grouping heading (intent 196): a
|
|
19
|
+
# malformed roadmap fails loudly, never silently parses as zero entries.
|
|
18
20
|
|
|
19
21
|
require_relative "lib/roadmap_queue"
|
|
20
22
|
require "json"
|
|
@@ -39,6 +41,11 @@ if roadmaps_dir.nil?
|
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
reader = RoadmapQueue.new(roadmaps_dir: roadmaps_dir, index_path: index)
|
|
42
|
-
|
|
44
|
+
begin
|
|
45
|
+
payload = which ? reader.which : reader.queue
|
|
46
|
+
rescue RoadmapSavepoint::MissingGroupingHeading => e
|
|
47
|
+
warn "roadmap-next: #{e.message}"
|
|
48
|
+
exit 3
|
|
49
|
+
end
|
|
43
50
|
puts JSON.generate(payload)
|
|
44
51
|
exit 0
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
# roadmap-savepoint rebuild --roadmap <path>
|
|
14
14
|
#
|
|
15
15
|
# Exits non-zero with a usage line on stderr for an unknown verb or a missing required flag.
|
|
16
|
+
# Exits 3 on rebuild when a roadmap has neither '## Batches' (canonical) nor '## Waves' (legacy)
|
|
17
|
+
# as its grouping heading (intent 196): a malformed roadmap fails loudly, never silently
|
|
18
|
+
# rebuilds an empty ledger.
|
|
16
19
|
|
|
17
20
|
require_relative "lib/roadmap_savepoint"
|
|
18
21
|
|
|
@@ -53,7 +56,12 @@ when "rebuild"
|
|
|
53
56
|
usage
|
|
54
57
|
exit 2
|
|
55
58
|
end
|
|
56
|
-
|
|
59
|
+
begin
|
|
60
|
+
count = RoadmapSavepoint.rebuild(roadmap)
|
|
61
|
+
rescue RoadmapSavepoint::MissingGroupingHeading => e
|
|
62
|
+
warn "rebuild: #{e.message}"
|
|
63
|
+
exit 3
|
|
64
|
+
end
|
|
57
65
|
puts "rebuilt #{count} line#{count == 1 ? '' : 's'}"
|
|
58
66
|
exit 0
|
|
59
67
|
|
package/scripts/update.rb
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
require_relative "lib/installer_core"
|
|
16
16
|
require_relative "doctor"
|
|
17
|
+
require_relative "lib/config_asks"
|
|
17
18
|
|
|
18
19
|
class Update < InstallerCore
|
|
19
20
|
PKG = "@zalom/plastic"
|
|
@@ -53,11 +54,51 @@ class Update < InstallerCore
|
|
|
53
54
|
end
|
|
54
55
|
puts "\u{2b06}\u{fe0f} Updating Plastic #{iv} \u{2192} #{res[:target]}"
|
|
55
56
|
exit_code = perform_switch(res[:target], agent_args(argv))
|
|
56
|
-
|
|
57
|
+
if exit_code == 0
|
|
58
|
+
announce_pending_config_asks(agent_key: primary_agent_key(argv))
|
|
59
|
+
run_post_update_doctor(full: argv.include?("--full-doctor"))
|
|
60
|
+
end
|
|
57
61
|
exit_code
|
|
58
62
|
end
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
# Print any pending config question(s) straight to stdout, right after a
|
|
66
|
+
# successful perform_switch (the moment the NEW config_asks.yml and
|
|
67
|
+
# write-config just landed on disk via the target versions own
|
|
68
|
+
# `install --reinstall`). Informational only, like run_post_update_doctor:
|
|
69
|
+
# rescued so a crash here can never undo or fail an update that already
|
|
70
|
+
# succeeded, and never changes the exit code this methods caller returns.
|
|
71
|
+
#
|
|
72
|
+
# A manifest that exists but cannot be read or parsed still prints
|
|
73
|
+
# something visible (the problem itself), it never prints nothing: a
|
|
74
|
+
# missing manifest is a legitimate quiet no-op, an unreadable one is not.
|
|
75
|
+
# agent_key defaults to "claude" and should be the agent this update is
|
|
76
|
+
# actually installing for, so an entry scoped to a different agent is not
|
|
77
|
+
# announced here.
|
|
78
|
+
def announce_pending_config_asks(agent_key: "claude", out: $stdout)
|
|
79
|
+
manifest_problem = ConfigAsks.manifest_error(plastic_home)
|
|
80
|
+
if manifest_problem
|
|
81
|
+
out.puts "\nCould not check for pending config questions: #{manifest_problem}"
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
pending = ConfigAsks.pending(plastic_home, agent_key)
|
|
86
|
+
return if pending.empty?
|
|
87
|
+
|
|
88
|
+
out.puts "\nConfig question(s) introduced by this update:"
|
|
89
|
+
pending.each do |entry|
|
|
90
|
+
out.puts " #{entry["question"]} (id: #{entry["id"]})"
|
|
91
|
+
Array(entry["options"]).each do |opt|
|
|
92
|
+
out.puts " - #{opt["label"]}"
|
|
93
|
+
out.puts " #{ConfigAsks.write_config_command(plastic_home, entry["key"], opt["value"])}"
|
|
94
|
+
end
|
|
95
|
+
out.puts " - Not now (keep the default)"
|
|
96
|
+
out.puts " #{ConfigAsks.dismiss_command(plastic_home, entry["id"])}"
|
|
97
|
+
end
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
out.puts " could not check config asks: #{e.message}"
|
|
100
|
+
end
|
|
101
|
+
|
|
61
102
|
# Run doctor after a successful update and print a human-readable summary.
|
|
62
103
|
# Defaults to the fast core tier (agent registration + core files + manifest
|
|
63
104
|
# sync, binary pass|fail, no store walk) so a newcomer's first post-update
|
|
@@ -118,6 +159,14 @@ class Update < InstallerCore
|
|
|
118
159
|
flags.empty? ? ["--claude"] : flags
|
|
119
160
|
end
|
|
120
161
|
|
|
162
|
+
# The single agent key this update is installing for, for config_asks
|
|
163
|
+
# scoping purposes: the first explicitly-flagged agent found in argv, or
|
|
164
|
+
# "claude" (matches agent_args' own default, and covers --all / no flag).
|
|
165
|
+
def primary_agent_key(argv)
|
|
166
|
+
match = agents.find { |a| argv.include?(a[:flag]) }
|
|
167
|
+
match ? match[:key] : "claude"
|
|
168
|
+
end
|
|
169
|
+
|
|
121
170
|
def fetch_dist_tags
|
|
122
171
|
raw = `npm view #{PKG} dist-tags --json 2>/dev/null`
|
|
123
172
|
return nil if raw.nil? || raw.strip.empty?
|
package/scripts/validate-intent
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
# --home <path> overrides the Plastic home (default: ~/.plastic).
|
|
17
17
|
|
|
18
18
|
require_relative "lib/intent_validator"
|
|
19
|
+
require_relative "lib/store_discovery"
|
|
19
20
|
|
|
20
21
|
def plastic_home(args)
|
|
21
22
|
if (i = args.index("--home")) && args[i + 1]
|
|
@@ -41,7 +42,8 @@ if intent_dir.nil?
|
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
dir = File.expand_path(intent_dir)
|
|
44
|
-
|
|
45
|
+
known_stores = StoreDiscovery.known_slugs(home)
|
|
46
|
+
result = IntentValidator.validate(dir, plastic_home: home, known_stores: known_stores)
|
|
45
47
|
|
|
46
48
|
if result[:ok]
|
|
47
49
|
puts "OK: #{dir}"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# validate-project - deterministic CLI over ProjectValidator (intent 190).
|
|
6
|
+
#
|
|
7
|
+
# Checks whether a registered project's spawn is structurally complete: the
|
|
8
|
+
# project directory exists, project.yml exists and parses, a root AGENTS.md
|
|
9
|
+
# exists, and the store/ and INDEX.md exist. plastic-project-creating runs
|
|
10
|
+
# this as a self-check before announcing a spawn as done; any agent or human
|
|
11
|
+
# can run it on one slug.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# validate-project <slug> [--home <path>]
|
|
15
|
+
#
|
|
16
|
+
# Exit codes: 0 (structurally complete), 1 (incomplete; report on stderr), 2 (usage).
|
|
17
|
+
# --home <path> overrides the Plastic home (default: ~/.plastic).
|
|
18
|
+
|
|
19
|
+
require_relative "lib/project_validator"
|
|
20
|
+
|
|
21
|
+
def plastic_home(args)
|
|
22
|
+
if (i = args.index("--home")) && args[i + 1]
|
|
23
|
+
File.expand_path(args[i + 1])
|
|
24
|
+
else
|
|
25
|
+
File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
home = plastic_home(ARGV)
|
|
30
|
+
|
|
31
|
+
home_value_index = (i = ARGV.index("--home")) ? i + 1 : nil
|
|
32
|
+
slug_index = ARGV.each_index.find do |idx|
|
|
33
|
+
arg = ARGV[idx]
|
|
34
|
+
!arg.start_with?("--") && idx != home_value_index
|
|
35
|
+
end
|
|
36
|
+
slug = slug_index && ARGV[slug_index]
|
|
37
|
+
|
|
38
|
+
if slug.nil?
|
|
39
|
+
warn "usage: validate-project <slug> [--home <path>]"
|
|
40
|
+
exit 2
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
result = ProjectValidator.validate(slug, plastic_home: home)
|
|
44
|
+
|
|
45
|
+
if result[:ok]
|
|
46
|
+
puts "OK: #{slug}"
|
|
47
|
+
exit 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
warn "INCOMPLETE: #{slug}"
|
|
51
|
+
result[:missing].each { |item| warn "missing: #{item}" }
|
|
52
|
+
result[:errors].each { |error| warn error }
|
|
53
|
+
exit 1
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Usage: write-config <key> <value>
|
|
6
|
+
# write-config <key> --push <value>
|
|
7
|
+
# Writes a value into the GLOBAL ~/.plastic/config.yml at a dot-notation key,
|
|
8
|
+
# creating intermediate hashes as needed. read-config's missing write-side
|
|
9
|
+
# counterpart. --push appends into an array at that key instead of overwriting
|
|
10
|
+
# it (deduped, creating the array if absent) -- used for dismissal lists like
|
|
11
|
+
# config_asks_dismissed.
|
|
12
|
+
#
|
|
13
|
+
# Concurrency: this is the sole intended writer of the owner's live
|
|
14
|
+
# config.yml, and it can be invoked by more than one agent session at once.
|
|
15
|
+
# The read-modify-write is guarded by an exclusive flock on a sibling lock
|
|
16
|
+
# file held across the whole critical section, and the write itself is a
|
|
17
|
+
# write-to-temp-then-rename in the same directory, so a reader never observes
|
|
18
|
+
# a half-written file and two concurrent writers never lose one another's key.
|
|
19
|
+
#
|
|
20
|
+
# Safety: if config.yml exists but cannot be parsed, or parses into something
|
|
21
|
+
# that is not a key/value mapping, this script refuses to write at all and
|
|
22
|
+
# exits non-zero. Silently treating a broken config.yml as an
|
|
23
|
+
# empty hash (the way read-config, a read-only script, safely can) would
|
|
24
|
+
# rewrite the owner's config.yml from nothing and destroy whatever was in it;
|
|
25
|
+
# a guard must fail milder than the bug it guards against, not worse.
|
|
26
|
+
#
|
|
27
|
+
# Environment:
|
|
28
|
+
# PLASTIC_HOME -- override ~/.plastic (for testing)
|
|
29
|
+
|
|
30
|
+
require "yaml"
|
|
31
|
+
require "json"
|
|
32
|
+
require "fileutils"
|
|
33
|
+
|
|
34
|
+
def usage_abort
|
|
35
|
+
$stderr.puts "Usage: write-config <key> <value>\n write-config <key> --push <value>"
|
|
36
|
+
exit 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
argv = ARGV.dup
|
|
40
|
+
push = false
|
|
41
|
+
if (idx = argv.index("--push"))
|
|
42
|
+
push = true
|
|
43
|
+
argv.delete_at(idx)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
key, raw_value = argv
|
|
47
|
+
usage_abort if key.nil? || key.empty? || raw_value.nil?
|
|
48
|
+
|
|
49
|
+
def coerce(raw)
|
|
50
|
+
JSON.parse(raw)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
raw
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
value = coerce(raw_value)
|
|
56
|
+
|
|
57
|
+
global_root = ENV.fetch("PLASTIC_HOME", File.expand_path("~/.plastic"))
|
|
58
|
+
config_path = File.join(global_root, "config.yml")
|
|
59
|
+
lock_path = "#{config_path}.lock"
|
|
60
|
+
|
|
61
|
+
FileUtils.mkdir_p(global_root)
|
|
62
|
+
|
|
63
|
+
File.open(lock_path, File::CREAT | File::RDWR, 0o644) do |lock_file|
|
|
64
|
+
lock_file.flock(File::LOCK_EX)
|
|
65
|
+
|
|
66
|
+
config =
|
|
67
|
+
if File.exist?(config_path)
|
|
68
|
+
begin
|
|
69
|
+
YAML.safe_load(File.read(config_path)) || {}
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
$stderr.puts "write-config: refusing to overwrite #{config_path}: could not parse it (#{e.message})"
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
{}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A config.yml that parses cleanly but is not a mapping (a bare string, a
|
|
79
|
+
# top-level list) would otherwise reach the assignment below and die with a
|
|
80
|
+
# raw IndexError or TypeError backtrace. Same refusal, same clean message:
|
|
81
|
+
# an unusable config.yml is never written over, and never merely confusing.
|
|
82
|
+
unless config.is_a?(Hash)
|
|
83
|
+
$stderr.puts "write-config: refusing to overwrite #{config_path}: it does not hold a key/value mapping (found #{config.class})"
|
|
84
|
+
exit 1
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
keys = key.split(".")
|
|
88
|
+
leaf = keys.pop
|
|
89
|
+
node = keys.reduce(config) { |acc, k| acc[k] ||= {} }
|
|
90
|
+
|
|
91
|
+
if push
|
|
92
|
+
current = node[leaf]
|
|
93
|
+
current = [] unless current.is_a?(Array)
|
|
94
|
+
current << value unless current.include?(value)
|
|
95
|
+
node[leaf] = current
|
|
96
|
+
else
|
|
97
|
+
node[leaf] = value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
tmp_path = "#{config_path}.tmp.#{Process.pid}.#{Time.now.to_f}"
|
|
101
|
+
File.write(tmp_path, YAML.dump(config))
|
|
102
|
+
File.rename(tmp_path, config_path)
|
|
103
|
+
|
|
104
|
+
puts "#{key} = #{node[leaf].inspect}"
|
|
105
|
+
end
|