@zalom/plastic 1.3.0 → 1.4.0

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 (52) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +24 -3
  3. package/hooks/hooks.json +5 -0
  4. package/hooks/links-gate +3 -0
  5. package/package.json +1 -1
  6. package/scripts/doctor.rb +164 -58
  7. package/scripts/end-intent +347 -43
  8. package/scripts/hook-links-gate +74 -0
  9. package/scripts/lib/bridge.rb +29 -1
  10. package/scripts/lib/config_asks.rb +110 -0
  11. package/scripts/lib/graph_rebuild.rb +30 -6
  12. package/scripts/lib/hook_registry.rb +2 -1
  13. package/scripts/lib/installer_core.rb +30 -7
  14. package/scripts/lib/intent_validator.rb +38 -10
  15. package/scripts/lib/links_gate.rb +140 -0
  16. package/scripts/lib/links_projection.rb +71 -12
  17. package/scripts/lib/power_tools.rb +57 -14
  18. package/scripts/lib/project_validator.rb +113 -0
  19. package/scripts/lib/qmd_hook.rb +12 -8
  20. package/scripts/lib/restore_intent_v1.rb +154 -0
  21. package/scripts/lib/roadmap_queue.rb +1 -1
  22. package/scripts/lib/roadmap_savepoint.rb +38 -10
  23. package/scripts/lib/store_discovery.rb +77 -0
  24. package/scripts/lib/store_provisioning.rb +21 -12
  25. package/scripts/new-intent +10 -12
  26. package/scripts/project-links +132 -35
  27. package/scripts/provision-project-store +18 -5
  28. package/scripts/read-config +1 -0
  29. package/scripts/rebuild-graph +42 -17
  30. package/scripts/restore-intent-v1 +288 -0
  31. package/scripts/roadmap-next +9 -2
  32. package/scripts/roadmap-savepoint +9 -1
  33. package/scripts/update.rb +50 -1
  34. package/scripts/validate-intent +3 -1
  35. package/scripts/validate-project +53 -0
  36. package/scripts/write-config +105 -0
  37. package/skills/auto/SKILL.md +16 -10
  38. package/skills/auto/references/end-tail.md +27 -13
  39. package/skills/install/SKILL.md +4 -4
  40. package/skills/intent-creating/SKILL.md +5 -0
  41. package/skills/intent-ending/SKILL.md +49 -36
  42. package/skills/project-creating/SKILL.md +29 -1
  43. package/skills/releasing/SKILL.md +37 -19
  44. package/skills/roadmap/SKILL.md +9 -7
  45. package/skills/roadmap/references/file-format.md +14 -10
  46. package/skills/roadmap/references/operations.md +22 -18
  47. package/skills/roadmap-continuing/SKILL.md +5 -5
  48. package/skills/roadmap-continuing/evals/evals.json +3 -3
  49. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  50. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
  51. package/skills/update/SKILL.md +30 -17
  52. 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
@@ -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 non-zero only on a missing required flag.
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
- payload = which ? reader.which : reader.queue
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
- count = RoadmapSavepoint.rebuild(roadmap)
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
- run_post_update_doctor(full: argv.include?("--full-doctor")) if exit_code == 0
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?
@@ -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
- result = IntentValidator.validate(dir, plastic_home: home)
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
@@ -39,7 +39,7 @@ ruby ~/.plastic/scripts/roadmap-next --roadmaps-dir <tier>/roadmaps
39
39
 
40
40
  Branch on `state`:
41
41
  - `dispatchable`: work its `dispatchable_queue` in `rank` order (the head is the next batch
42
- entry). These are the current batch's `queued` intents, parallel-safe within the wave.
42
+ entry). These are the current batch's `queued` intents, parallel-safe within the batch.
43
43
  - `in_flight`: the frontier batch is still delivering. Report it and wait. Do NOT dispatch a
44
44
  later batch and do NOT fall through to the dashboard, the roadmap is live.
45
45
  - `none` or `exhausted`: no roadmap, or nothing left to dispatch. Fall back to the dashboard
@@ -305,19 +305,25 @@ During initial project creation, all decisions are non-destructive by definition
305
305
  - Update `chain` in the current intent's frontmatter
306
306
  6. Run the mechanical close through `plastic-intent-ending`: it owns steps 1-6 of the Done
307
307
  procedure (outcome/INDEX/savepoint/commit, disarm, and the QMD reindex last) as ONE
308
- delegation, not five separate one-liners restated here. Run its backing script for the
309
- outcome/INDEX/savepoint/commit core, passing `--index-note` with a rich Completed/
310
- Abandoned entry description (mode/tier, what shipped or why abandoned, suite result):
308
+ delegation, not five separate one-liners restated here. `scripts/end-intent` now performs
309
+ steps 1-5 itself, INCLUDING disarm (worktree release plus clearing `delivery.lock`): a
310
+ single call closes the intent AND clears its lock, so exit 0 means both are done. Pass
311
+ `--session` (this session's id, or rely on the `CLAUDE_CODE_SESSION_ID` fallback) so
312
+ disarm resolves the right bridge, and `--index-note` with a rich Completed/Abandoned entry
313
+ description (mode/tier, what shipped or why abandoned, suite result):
311
314
  ```bash
312
315
  ruby ~/.plastic/scripts/end-intent --store <store_path> --id <ID> --disposition delivered \
316
+ --session "$CLAUDE_CODE_SESSION_ID" \
313
317
  --index-note "<mode, tier>; <what shipped>; <suite result>"
314
318
  ```
315
- (Use `--disposition abandoned` when the intent is being moved to `## Abandoned`.) Then
316
- follow `plastic-intent-ending`'s Step 5 (disarm: `Bridge.disarm_auto` on this auto/curator
317
- path, the plain-remove branch) and Step 6 (QMD reindex, async, last) exactly as that skill
318
- states them. Never leave an orphaned worktree; run `git worktree prune` on a stale
319
- reference. If any of this ever needs to change, change `plastic-intent-ending`, not this
320
- skill.
319
+ (Use `--disposition abandoned` when the intent is being moved to `## Abandoned`.) A
320
+ non-zero exit needs attention before moving on: 4 means a live foreign session holds the
321
+ lock (back off), 5 means the code worktree is dirty (commit/stash first, or pass
322
+ `--discard-worktree-changes` deliberately), 3 means disarm ran but the lock is still
323
+ present (run `/plastic-doctor check the lock status`). Only Step 6 (QMD reindex, async,
324
+ last) remains a separate action after this call succeeds. Never leave an orphaned
325
+ worktree; run `git worktree prune` on a stale reference. If any of this ever needs to
326
+ change, change `plastic-intent-ending`, not this skill.
321
327
  7. Notify user (Done briefing): brief per `references/human-report-contract.md`
322
328
  (State: the delivered impact; Risk: residual risk; Call: the decision left to you, merge,
323
329
  release, or accept). See `outcome.md` for details.