@zalom/plastic 1.2.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 (63) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +68 -6
  3. package/README.md +5 -0
  4. package/agents/plastic-advisor.md +56 -0
  5. package/agents/plastic-enforcer.md +9 -1
  6. package/agents/plastic-faux-advisor.md +174 -0
  7. package/agents/plastic-future-intent-researcher.md +1 -0
  8. package/hooks/hooks.json +5 -0
  9. package/hooks/links-gate +3 -0
  10. package/hooks/statusline +1 -0
  11. package/package.json +1 -1
  12. package/scripts/doctor.rb +164 -58
  13. package/scripts/end-intent +347 -43
  14. package/scripts/hook-links-gate +74 -0
  15. package/scripts/install.rb +8 -0
  16. package/scripts/lib/agent_models.rb +36 -9
  17. package/scripts/lib/bridge.rb +29 -1
  18. package/scripts/lib/config_asks.rb +110 -0
  19. package/scripts/lib/graph_rebuild.rb +30 -6
  20. package/scripts/lib/hook_registry.rb +2 -1
  21. package/scripts/lib/installer_core.rb +130 -23
  22. package/scripts/lib/intent_validator.rb +38 -10
  23. package/scripts/lib/links_gate.rb +140 -0
  24. package/scripts/lib/links_projection.rb +71 -12
  25. package/scripts/lib/power_tools.rb +57 -14
  26. package/scripts/lib/project_validator.rb +113 -0
  27. package/scripts/lib/qmd_hook.rb +12 -8
  28. package/scripts/lib/restore_intent_v1.rb +154 -0
  29. package/scripts/lib/roadmap_queue.rb +1 -1
  30. package/scripts/lib/roadmap_savepoint.rb +38 -10
  31. package/scripts/lib/store_discovery.rb +77 -0
  32. package/scripts/lib/store_provisioning.rb +21 -12
  33. package/scripts/new-intent +10 -12
  34. package/scripts/project-links +132 -35
  35. package/scripts/provision-project-store +18 -5
  36. package/scripts/read-config +1 -0
  37. package/scripts/rebuild-graph +42 -17
  38. package/scripts/restore-intent-v1 +288 -0
  39. package/scripts/roadmap-next +9 -2
  40. package/scripts/roadmap-savepoint +9 -1
  41. package/scripts/update.rb +50 -1
  42. package/scripts/validate-intent +3 -1
  43. package/scripts/validate-project +53 -0
  44. package/scripts/write-config +105 -0
  45. package/skills/agent-advisor/SKILL.md +92 -0
  46. package/skills/agent-advisor/references/advisor-protocol.md +245 -0
  47. package/skills/auto/SKILL.md +26 -12
  48. package/skills/auto/references/end-tail.md +27 -13
  49. package/skills/install/SKILL.md +30 -2
  50. package/skills/intent-creating/SKILL.md +5 -0
  51. package/skills/intent-ending/SKILL.md +49 -36
  52. package/skills/project-creating/SKILL.md +29 -1
  53. package/skills/releasing/SKILL.md +37 -19
  54. package/skills/roadmap/SKILL.md +9 -7
  55. package/skills/roadmap/references/file-format.md +14 -10
  56. package/skills/roadmap/references/operations.md +22 -18
  57. package/skills/roadmap-continuing/SKILL.md +5 -5
  58. package/skills/roadmap-continuing/evals/evals.json +3 -3
  59. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  60. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
  61. package/skills/update/SKILL.md +34 -4
  62. package/templates/config.yml +31 -6
  63. 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
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: plastic-agent-advisor
3
+ description: >-
4
+ Consult the advisor for expensive reasoning: one-way doors, plans, adversarial
5
+ review of a plan or conclusion before an irreversible step, a deadlock after two
6
+ failed attempts, or ranking several plausible options. Use when the user asks for
7
+ a second opinion, a hard design decision, an architecture review, help breaking a
8
+ deadlock, or says "ask the advisor". Also sets which advisor is the default when
9
+ asked ("make Fable my advisor", "switch my advisor", "use the real advisor").
10
+ user-invocable: true
11
+ ---
12
+
13
+ # Agent Advisor
14
+
15
+ Plastic ships two consultation agents, never dispatched by the auto pipeline, summoned
16
+ only when you decide the reasoning is worth buying:
17
+
18
+ - **`plastic-advisor`** ("the real advisor"): the frontier model itself, expensive,
19
+ billed through usage credits. Spawn it for a few rounds on the hardest problem, then
20
+ close the session.
21
+ - **`plastic-faux-advisor`** ("the imitation advisor"): an ordinary model carrying the
22
+ same reasoning discipline inline in its own body, so it reasons the same disciplined
23
+ way at a fraction of the cost. The cheaper default.
24
+
25
+ ## When to consult (and when not to)
26
+
27
+ Buy a consultation for: decisions with one-way doors (architecture, migration order,
28
+ public contracts); turning a goal plus evidence into a step plan with checks;
29
+ adversarial review of your plan or conclusion before an irreversible step; a deadlock
30
+ after two failed attempts where you cannot say why; ranking several plausible options
31
+ when the ordering decides where you spend the next day.
32
+
33
+ Never buy a consultation for: anything a tool can answer (search, reading code, running
34
+ tests, documentation), writing code at volume, confirming a decision you already made,
35
+ style or naming a linter would settle, or anything reversible and cheap you have not
36
+ tried first. The full buy/never-buy list, the tier table, and the entry test live in
37
+ `references/advisor-protocol.md`; read it before writing a brief for the first time in
38
+ a session.
39
+
40
+ ## Routing: which advisor answers
41
+
42
+ 1. Read the harness-scoped config: `advisor.claude.default`. If unset, fall back to
43
+ `advisor.claude.secondary`, then to `plastic-faux-advisor`.
44
+ 2. If the user names which advisor they want ("ask the real one", "use Fable", "ask the
45
+ cheap one"), honor that directly and dispatch `advisor.claude.primary` (default
46
+ `plastic-advisor`) or `advisor.claude.secondary` (default `plastic-faux-advisor`)
47
+ accordingly, overriding step 1 for this consultation only.
48
+ 3. If `advisor.enabled` reads `false`, neither advisor agent nor this skill is
49
+ installed; this step should not be reachable, but if it is, tell the user the
50
+ advisor is disabled and point at "Setting the default" below.
51
+ 4. Dispatch the resolved agent with a brief built per `references/advisor-protocol.md`
52
+ section 4 (natural prose, the block is a completeness check, not a form to fill).
53
+ State TIER (S, M, or L) and EFFORT explicitly; classify low and prove your way up,
54
+ never open high "to be safe".
55
+ 5. Consume the answer per the protocol's section 5: run the Operating Manual's
56
+ five-question self-test on the advisor's plan before executing it. Advice is input,
57
+ not authority; the plan is the advisor's, the outcome is yours.
58
+
59
+ Read the resolved config value with:
60
+
61
+ ```bash
62
+ ruby ~/.plastic/scripts/read-config advisor.claude.default --project <repo>
63
+ ```
64
+
65
+ (Omit `--project` outside a registered project; falls back to the global value.)
66
+
67
+ ## Setting the default advisor
68
+
69
+ When asked to change the default ("make Fable my advisor", "switch my advisor", "use
70
+ the cheaper one by default"), present the two options in plain language and write the
71
+ choice:
72
+
73
+ - **Faux Fable** (`plastic-faux-advisor`, recommended): an ordinary model carrying the
74
+ frontier reasoning instructions. Much cheaper, available on any plan, reasons in the
75
+ same disciplined way.
76
+ - **Fable 5** (`plastic-advisor`): the frontier model itself. The strongest reasoning
77
+ available, billed through usage credits, so summon it for a few rounds and close it.
78
+
79
+ These are the same two options the installer offers at install and update time. Write
80
+ the choice to `advisor.claude.default` in the global `~/.plastic/config.yml` (or the
81
+ project's `.plastic_store/config.yml` when the user scopes the change to one project):
82
+ read the file as YAML, set `advisor.claude.default` to the agent name (`plastic-advisor`
83
+ or `plastic-faux-advisor`, never a model name or nickname), and write it back. Confirm
84
+ the new default back to the user in one line.
85
+
86
+ ## References
87
+
88
+ - `references/advisor-protocol.md`: the full shipped Advisor Protocol (what to buy,
89
+ tiers and effort, the entry test, how to write a brief that earns its cost, the
90
+ answer contract, session economics, anti-patterns). Read it before the first
91
+ consultation in a session; the second consultation in the same advisor thread costs a
92
+ fraction of the first, so keep follow-ups on one thread rather than opening a new one.