@zalom/plastic 2.0.1 → 2.0.3

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 (50) hide show
  1. package/PLASTIC.md +2 -1
  2. package/README.md +40 -33
  3. package/agents/plastic-enforcer.md +13 -11
  4. package/agents/plastic-executor.md +2 -1
  5. package/agents/plastic-primary-advisor.md +2 -2
  6. package/agents/plastic-secondary-advisor.md +2 -2
  7. package/bin/plastic +2 -0
  8. package/docs/help/agent-architecture.md +9 -8
  9. package/docs/help/agent-report-contract.md +6 -4
  10. package/docs/help/completion-and-done.md +20 -4
  11. package/docs/help/human-report-contract.md +23 -20
  12. package/docs/help/knowledge-graph.md +2 -2
  13. package/docs/help/lifecycle-and-savepoints.md +9 -5
  14. package/docs/help/locks-and-worktrees.md +4 -3
  15. package/docs/help/maintenance-and-revisions.md +2 -2
  16. package/docs/help/roadmaps.md +16 -11
  17. package/docs/help/track-1-guided.md +48 -30
  18. package/docs/help/track-2-auto.md +35 -10
  19. package/docs/help/track-3-projects-and-roadmaps.md +10 -7
  20. package/docs/help/tutorial.md +421 -0
  21. package/package.json +1 -1
  22. package/scripts/end-intent +312 -69
  23. package/scripts/lib/arm.rb +28 -10
  24. package/scripts/lib/cli/commands/auto_lock.rb +17 -5
  25. package/scripts/lib/cli/commands/auto_take.rb +38 -1
  26. package/scripts/lib/cli/commands/doctor.rb +2 -2
  27. package/scripts/lib/cli/commands/intent_command.rb +6 -1
  28. package/scripts/lib/cli/commands/intent_end.rb +15 -2
  29. package/scripts/lib/cli/commands/intent_step.rb +8 -0
  30. package/scripts/lib/cli/commands/project_links.rb +5 -1
  31. package/scripts/lib/cli/commands/project_new.rb +24 -1
  32. package/scripts/lib/cli/commands/render.rb +3 -1
  33. package/scripts/lib/cli/commands/roadmap_next.rb +10 -3
  34. package/scripts/lib/cli/commands/roadmap_show.rb +21 -1
  35. package/scripts/lib/cli/commands/session_commit.rb +1 -1
  36. package/scripts/lib/cli/commands/sync.rb +10 -1
  37. package/scripts/lib/hook_replay.rb +51 -45
  38. package/scripts/lib/installer_core.rb +12 -0
  39. package/scripts/lib/node_input.rb +13 -5
  40. package/scripts/lib/revisions_writer.rb +1 -2
  41. package/scripts/lib/roadmap_queue.rb +12 -4
  42. package/scripts/lib/runner_dispatch.rb +8 -7
  43. package/scripts/lib/session_git.rb +42 -1
  44. package/scripts/lib/untouched_scaffold.rb +51 -0
  45. package/scripts/plastic-lock +24 -28
  46. package/scripts/project-links +6 -21
  47. package/scripts/roadmap-graph +10 -4
  48. package/scripts/rollback.rb +5 -1
  49. package/scripts/update.rb +7 -1
  50. package/skills/_decision-tables.md +3 -3
@@ -46,9 +46,9 @@ module RunnerDispatch
46
46
 
47
47
  HARD_CAP_RE = /\Ais at its dispatch cap \((\d+)\/(\d+)\)\z/.freeze
48
48
 
49
- # D8 (355, n6): the agent every dispatched, non-decision node names - a
50
- # role, never a harness (matrix 6.5), and never a consultation advisor, which
51
- # stays a deliberate, never-auto-dispatched consultation agent.
49
+ # The spawn block's default agent. A dispatched node names its own kind's
50
+ # node agent instead (HarnessAdapter.agent_type_for_kind), the same agent
51
+ # the dispatch line under it names (acceptance N12).
52
52
  SPAWN_AGENT = "plastic-executor"
53
53
 
54
54
  # matrix 6.1/6.2: one spawn block per dispatched node - agent, the model
@@ -237,7 +237,8 @@ module RunnerDispatch
237
237
  provisioned = RunnerPolicy.worktree?(kind) ? worktree.provision(context, node: node, kind: kind, runner: runner)
238
238
  : unprovisioned
239
239
  node_reader = lambda do |intent_dir:|
240
- { "code" => provisioned[:path], "code_branch" => provisioned[:branch], "provisioned" => !!provisioned[:provisioned] }
240
+ { "code" => provisioned[:path], "code_branch" => provisioned[:branch], "provisioned" => !!provisioned[:provisioned],
241
+ "read_only" => !RunnerPolicy.worktree?(kind) }
241
242
  end
242
243
 
243
244
  # Row 5.18: build the node input BEFORE writing `running` - a `running` line
@@ -289,7 +290,7 @@ module RunnerDispatch
289
290
 
290
291
  test_command = NodeInput.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
291
292
  spawn = spawn_block(model: model, effort: effort, input: build_result[:path], test_command: test_command,
292
- call_cap: calls_cap)
293
+ call_cap: calls_cap, agent: HarnessAdapter.agent_type_for_kind(kind))
293
294
 
294
295
  {
295
296
  ok: true,
@@ -409,9 +410,9 @@ module RunnerDispatch
409
410
  end
410
411
 
411
412
  # Row 5.32: re-arms delivery.lock for a resumed session with a new id -
412
- # `plastic-lock arm` is the shipped command that takes ownership again.
413
+ # `plastic auto take` is the public command that takes ownership again.
413
414
  def rearm_command(intent_dir)
414
- "plastic-lock arm --intent-dir #{intent_dir}"
415
+ "plastic auto take #{File.basename(intent_dir.to_s).split("--").first}"
415
416
  end
416
417
 
417
418
  # --- refusals and the report -------------------------------------------------
@@ -7,6 +7,7 @@ require_relative "worktree"
7
7
  require_relative "scaffold_intent"
8
8
  require_relative "session_ledger"
9
9
  require_relative "active_delivery"
10
+ require_relative "lock"
10
11
 
11
12
  # SessionGit - the branch model and per-repo flow settings behind
12
13
  # `scripts/session-commit` (intent 300), which is how a checklist item
@@ -306,6 +307,44 @@ module SessionGit
306
307
  parts.each_cons(2).any? { |a, b| a == ".claude" && b == "worktrees" }
307
308
  end
308
309
 
310
+ # Why an agent-owned branch was left alone, named from the real delivery
311
+ # lock (acceptance N5). The branch or worktree name gives the intent id; the
312
+ # repository's slug in projects.yml gives its store. A branch whose intent
313
+ # cannot be found is still left alone, and says so plainly.
314
+ def delivery_state(repo, branch, plastic_home, session)
315
+ intent_dir = delivery_intent_dir(repo, branch, plastic_home)
316
+ return "it is an intent delivery branch" if intent_dir.nil?
317
+
318
+ id = File.basename(intent_dir)[/\A\d+/]
319
+ owner = Lock.read(intent_dir).to_h["owner_session"].to_s
320
+ return "it is intent #{id}'s delivery branch and no delivery lock is held" if owner.empty?
321
+ if !blank?(session) && owner.start_with?(session.to_s)
322
+ return "this session holds intent #{id}'s delivery lock; commit delivery work there with git"
323
+ end
324
+
325
+ "intent #{id}'s delivery lock is held by #{owner}"
326
+ end
327
+
328
+ def delivery_intent_dir(repo, branch, plastic_home)
329
+ parts = File.expand_path(repo.to_s).split(File::SEPARATOR)
330
+ at = parts.each_cons(2).find_index { |a, b| a == ".claude" && b == "worktrees" }
331
+ name = branch.to_s.start_with?("plastic/") ? branch.to_s.delete_prefix("plastic/") : parts.last
332
+ main = at ? parts[0...at].join(File::SEPARATOR) : File.expand_path(repo.to_s)
333
+ id = name[/\A(\d+)--/, 1]
334
+ slug = slug_for_repo(main, plastic_home)
335
+ return nil if id.nil? || slug.nil?
336
+
337
+ Dir.glob(File.join(Plastic::StoreLayout.root(plastic_home, slug), "store", "#{id}--*")).first
338
+ end
339
+
340
+ def slug_for_repo(main, plastic_home)
341
+ projects = YAML.safe_load_file(File.join(plastic_home, "projects.yml")).fetch("projects")
342
+ target = File.realpath(main)
343
+ projects.find { |_slug, info| File.exist?(info["path"].to_s) && File.realpath(info["path"]) == target }&.first
344
+ rescue StandardError
345
+ nil
346
+ end
347
+
309
348
  def stage_and_commit(dir, subject, runner:, body: nil)
310
349
  runner.run("-C", dir, "add", "-A")
311
350
  args = ["-C", dir, "commit", "-m", subject]
@@ -340,7 +379,9 @@ module SessionGit
340
379
 
341
380
  branch_now = current_branch(repo, runner: runner)
342
381
  return note("detached HEAD: no commit") if branch_now == "HEAD"
343
- return note("left branch #{branch_now} untouched: agent lock") if agent_owned?(repo, branch_now)
382
+ if agent_owned?(repo, branch_now)
383
+ return note("left branch #{branch_now} untouched: #{delivery_state(repo, branch_now, plastic_home, session)}")
384
+ end
344
385
  return note("repository has no commits yet: no commit") unless has_commits?(repo, runner: runner)
345
386
 
346
387
  flow, flow_notes = load_flow(cwd: cwd, repo: repo, plastic_home: plastic_home, runner: runner)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "savepoint"
4
+ require_relative "backfill_intent"
5
+
6
+ # Finds an intent that is still exactly what new-intent created, so
7
+ # end-intent can refuse to close it as delivered.
8
+ #
9
+ # The rule is narrow so older intents still close. An intent is untouched
10
+ # only when all of these hold:
11
+ #
12
+ # - spec.md, plan.md, checklist.md and outcome.md exist and are still
13
+ # unedited placeholders, with no ticked checklist item;
14
+ # - there is no action, node or graph file;
15
+ # - savepoint.md holds only What lines;
16
+ # - the code worktree, if there is one, has no changes.
17
+ #
18
+ # An older intent with missing files is never untouched.
19
+ module UntouchedScaffold
20
+ module_function
21
+
22
+ LIFECYCLE = %w[spec.md plan.md checklist.md outcome.md].freeze
23
+ SCAFFOLD_LINE = /\A\S+\s+What\s/
24
+ TICKED_ITEM = /^\s*- \[[xX]\] /
25
+
26
+ def reason(intent_dir, templates: {}, worktree_changed: ->(_dir) { false })
27
+ return nil unless placeholders_untouched?(intent_dir, templates)
28
+ return nil if Savepoint.has_real_action?(intent_dir)
29
+ return nil if File.exist?(File.join(intent_dir, "graph.md"))
30
+ return nil unless scaffold_savepoint?(File.join(intent_dir, "savepoint.md"))
31
+ return nil if worktree_changed.call(intent_dir)
32
+
33
+ "spec.md, plan.md, checklist.md and outcome.md are still placeholders, " \
34
+ "no action, node or graph was written, the savepoint records no work, " \
35
+ "and the code worktree has no changes"
36
+ end
37
+
38
+ def placeholders_untouched?(intent_dir, templates)
39
+ paths = LIFECYCLE.map { |rel| File.join(intent_dir, rel) }
40
+ return false unless paths.all? { |path| File.exist?(path) && !Savepoint.stage_file_present?(path) }
41
+ return false if File.read(File.join(intent_dir, "checklist.md")).match?(TICKED_ITEM)
42
+
43
+ BackfillIntent.classify(intent_dir, templates: templates)[:edited].empty?
44
+ end
45
+
46
+ def scaffold_savepoint?(path)
47
+ return false unless File.exist?(path)
48
+
49
+ File.readlines(path).reject { |line| line.strip.empty? }.all? { |line| line.match?(SCAFFOLD_LINE) }
50
+ end
51
+ end
@@ -15,8 +15,9 @@
15
15
  # arm take the intent for this session (intent 307): acquire
16
16
  # delivery.lock stamped with --mode (default auto) and the
17
17
  # provenance flags, provision the code worktree, and point the
18
- # session at the intent; exit 1 on a held, stale, excluded, or
19
- # corrupt lock with the resolving doctor hint
18
+ # session at the intent; exit 3 on a held, stale, or excluded
19
+ # lock (another owner's), exit 1 on a corrupt one, each with the
20
+ # public command that resolves it
20
21
  # who compact, read-only durable owner/activity view
21
22
  # status report the lock file, its freshness, the derived worktree,
22
23
  # whether this session is the one delivering the intent, and
@@ -36,7 +37,8 @@
36
37
  # configured project roots): exactly one fresh lock this session owns or
37
38
  # delegates for. Session resolution no longer reads any per-session state
38
39
  # file; that path was retired in 2.0 (intent 307) and 344 G11.
39
- # Exit 0 on success/report; exit 1 when the verb is blocked.
40
+ # Exit 0 on success/report; exit 3 when another session owns the lock (arm,
41
+ # fix, release, reclaim); exit 1 when the verb is otherwise blocked.
40
42
 
41
43
  require_relative "lib/store_layout"
42
44
  require "json"
@@ -82,7 +84,6 @@ usage! if opts[:mode] && !%w[auto guided].include?(opts[:mode])
82
84
  session = opts[:session]
83
85
  session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.strip.empty?
84
86
  harness = opts[:harness]
85
- hint_harness = harness || "claude"
86
87
  # The plastic home every hook uses (PLASTIC_HOME, else ~/.plastic); Arm takes the
87
88
  # OS home above it, the same convention Worktree resolves projects.yml from.
88
89
  plastic_home_env = ENV["PLASTIC_HOME"].to_s.strip
@@ -149,8 +150,11 @@ intent_id = Arm.intent_id_for(dir)
149
150
  store = File.dirname(dir)
150
151
  key = Arm.resolve_session(session, store: store, intent_id: intent_id)
151
152
 
152
- def doctor_hint(verb_text, harness)
153
- "run #{Lock.skill_ref('plastic-doctor', harness: harness)} #{verb_text}"
153
+ # Exit 3 is Plastic's refusal: the lock belongs to another session, and only
154
+ # its owner (or the person who owns the work) may resolve it.
155
+ def refuse(message)
156
+ warn "plastic-lock: #{message}"
157
+ exit 3
154
158
  end
155
159
 
156
160
  case verb
@@ -167,19 +171,16 @@ when "arm"
167
171
  "worktree" => wt
168
172
  )
169
173
  when :held
170
- warn "plastic-lock: delivery lock for intent #{intent_id} is held by session " \
171
- "#{result[:lock] && result[:lock]['owner_session']}; #{doctor_hint('check the lock status', hint_harness)}"
172
- exit 1
174
+ refuse "delivery lock for intent #{intent_id} is held by session " \
175
+ "#{result[:lock] && result[:lock]['owner_session']}; plastic auto lock status #{intent_id} shows it"
173
176
  when :stale
174
- warn "plastic-lock: delivery lock for intent #{intent_id} is stale (owner " \
175
- "#{result[:lock] && result[:lock]['owner_session']}); #{doctor_hint('reclaim the lock to take it over with an audit', hint_harness)}"
176
- exit 1
177
+ refuse "delivery lock for intent #{intent_id} is stale (owner " \
178
+ "#{result[:lock] && result[:lock]['owner_session']}); #{Arm.stale_hint(intent_id)}"
177
179
  when :excluded
178
- warn "plastic-lock: a #{result[:lock] && result[:lock]['type']} lock is active on intent " \
179
- "#{intent_id}; #{doctor_hint('check the lock status', hint_harness)}"
180
- exit 1
180
+ refuse "a #{result[:lock] && result[:lock]['type']} lock is active on intent " \
181
+ "#{intent_id}; plastic auto lock status #{intent_id} shows it"
181
182
  when :corrupt
182
- warn "plastic-lock: delivery.lock for intent #{intent_id} is unreadable; #{doctor_hint('fix the lock', hint_harness)}"
183
+ warn "plastic-lock: delivery.lock for intent #{intent_id} is unreadable; plastic auto lock fix #{intent_id} rewrites it"
183
184
  exit 1
184
185
  when :inline_refused
185
186
  warn "plastic-lock: refusing to arm intent #{intent_id} from a conversation session " \
@@ -207,32 +208,27 @@ when "status"
207
208
  when "fix"
208
209
  report = Arm.repair(intent_dir: dir, session: key, home: home, harness: harness,
209
210
  agent: opts[:agent], model: opts[:model], thread: opts[:thread],
210
- run_mode: opts[:mode], hint_harness: hint_harness)
211
+ run_mode: opts[:mode])
211
212
  puts JSON.pretty_generate(report)
212
213
  unless report["status"] == "repaired"
213
- warn "plastic-lock: #{report['status']} by #{report['owner']}" \
214
- "#{report['hint'] ? " (#{report['hint']})" : ''}"
215
- exit 1
214
+ refuse "#{report['status']} by #{report['owner']}" \
215
+ "#{report['hint'] ? " (#{report['hint']})" : ''}"
216
216
  end
217
217
  when "release"
218
218
  result = Lock.release(dir, session: key)
219
- if result == :not_owner
220
- warn "plastic-lock: not the owner; run plastic-lock status"
221
- exit 1
222
- end
219
+ refuse "not the owner; plastic auto lock status #{intent_id} shows who is" if result == :not_owner
223
220
  puts "released (#{result})"
224
221
  when "reclaim"
225
222
  status, lock_data = Lock.takeover(dir, session: key, harness: harness,
226
223
  agent: opts[:agent], model: opts[:model],
227
224
  thread: opts[:thread], run_mode: opts[:mode])
228
225
  if status == :fresh
229
- warn "plastic-lock: lock is FRESH and held by #{lock_data['owner_session']}; " \
230
- "back off (no silent reclaim)"
231
- exit 1
226
+ refuse "lock is FRESH and held by #{lock_data['owner_session']}; " \
227
+ "back off (no silent reclaim)"
232
228
  end
233
229
  report = Arm.repair(intent_dir: dir, session: key, home: home, harness: harness,
234
230
  agent: opts[:agent], model: opts[:model], thread: opts[:thread],
235
- run_mode: opts[:mode], hint_harness: hint_harness)
231
+ run_mode: opts[:mode])
236
232
  puts JSON.pretty_generate(report)
237
233
  when "delegate"
238
234
  usage! if opts[:delegate].nil?
@@ -48,10 +48,6 @@ require_relative "lib/revisions_writer"
48
48
  class ProjectLinks
49
49
  DEFAULT_HOME = File.join(Dir.home, ".plastic")
50
50
 
51
- # The 72 intent dir audit destination (relative to plastic_home).
52
- DEFAULT_AUDIT_REL =
53
- "projects/plastic/store/72--links-graph-projection/resources/audit--links-projection.md"
54
-
55
51
  def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil,
56
52
  drop_unbacked_links: false, intent: nil, store: nil)
57
53
  @plastic_home = plastic_home
@@ -60,20 +56,7 @@ class ProjectLinks
60
56
  @intent = intent && intent.to_s
61
57
  @store = store
62
58
 
63
- # A dry run must NOT stomp the canonical audit (humans run --dry-run to review
64
- # the plan). With no explicit --audit-path, a dry run writes a `.dry-run.md`
65
- # sibling, leaving the canonical real-run audit untouched. An explicit
66
- # --audit-path is always honored verbatim (tests inject it). Mirrors
67
- # RebuildGraph's discipline exactly.
68
- canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
69
- @audit_path =
70
- if audit_path
71
- audit_path
72
- elsif dry_run
73
- canonical.sub(/\.md\z/, ".dry-run.md")
74
- else
75
- canonical
76
- end
59
+ @audit_path = audit_path || File.join(Plastic::StoreLayout.global_root(plastic_home), "resources", "audit--links-projection.md")
77
60
  end
78
61
 
79
62
  attr_reader :plastic_home, :dry_run, :audit_path, :drop_unbacked_links, :intent, :store
@@ -330,9 +313,11 @@ class ProjectLinks
330
313
  "(ambiguous: multiple ## Links headings)"
331
314
  end
332
315
 
333
- # Always write the audit (even in dry-run, so the human reviews the plan).
316
+ # Return the audit for previews; save it only when applying changes.
334
317
  def emit_audit(store_list, results)
335
318
  text = render_audit(store_list, results)
319
+ return text if dry_run
320
+
336
321
  FileUtils.mkdir_p(File.dirname(audit_path))
337
322
  File.write(audit_path, text)
338
323
  text
@@ -348,7 +333,7 @@ class ProjectLinks
348
333
 
349
334
  def render_audit(store_list, results)
350
335
  lines = []
351
- lines << "# Audit: store-wide ## Links projection (intent 72)"
336
+ lines << "# Audit: store-wide Links projection"
352
337
  lines << ""
353
338
  lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
354
339
  lines << "Scoped to intent #{intent} only (--intent)." if intent
@@ -482,6 +467,6 @@ if $PROGRAM_NAME == __FILE__
482
467
  "regenerated #{totals[:regenerated]}, added #{totals[:added]}, " \
483
468
  "unchanged #{totals[:unchanged]}, failed #{totals[:failed]} " \
484
469
  "across #{results.size} store(s)."
485
- puts "Audit: #{tool.audit_path}"
470
+ puts(dry ? "Dry run: no files written." : "Audit: #{tool.audit_path}")
486
471
  exit 1 if tool.any_failed?(results)
487
472
  end
@@ -62,6 +62,7 @@ module RoadmapGraphCli
62
62
  end
63
63
  if result[:cycle]
64
64
  warn "roadmap-graph check: cyclic graph: #{result[:cycle].join(' > ')}"
65
+ warn_dangling(result[:dangling])
65
66
  return 1
66
67
  end
67
68
  if result[:reason]
@@ -73,14 +74,19 @@ module RoadmapGraphCli
73
74
  puts "Ready: #{result[:ready].join(', ')}"
74
75
  puts "Dead ends: #{result[:dead_ends].join(', ')}" unless result[:dead_ends].empty?
75
76
 
76
- unless result[:dangling].empty?
77
- warn "roadmap-graph check: graph names #{result[:dangling].map(&:inspect).join(', ')}, no batch entry"
78
- return 1
79
- end
77
+ return 1 if warn_dangling(result[:dangling])
80
78
 
81
79
  0
82
80
  end
83
81
 
82
+ # Names every graph id no batch lists; true when there was one.
83
+ def warn_dangling(dangling)
84
+ return false if dangling.to_a.empty?
85
+
86
+ warn "roadmap-graph check: graph names #{dangling.map(&:inspect).join(', ')}, no batch entry"
87
+ true
88
+ end
89
+
84
90
  def render(path, dry_run)
85
91
  result = RoadmapRender.write(path, dry_run: dry_run)
86
92
  unless result[:ok]
@@ -89,12 +89,16 @@ class Rollback < InstallerCore
89
89
  end
90
90
 
91
91
  def switch_to(target, current)
92
+ return 3 if refuse_incompatible_store_layout(target)
93
+
92
94
  action = action_for(target, current)
93
95
  puts "#{action == "downgrade" ? "\u{23ea}" : "\u{23e9}"} #{current} \u{2192} #{target} (#{action})"
94
96
  prepare_switch(target, current)
95
97
  cmd = ["npx", "#{PKG}@#{target}", "install", "--reinstall", "--ledger-action", action, *harness_flags]
96
98
  puts " $ #{cmd.join(" ")}"
97
- system(*cmd) ? 0 : 1
99
+ return 1 unless system({"PLASTIC_PACKAGE_ROOT" => nil}, *cmd)
100
+
101
+ installed_version == target ? 0 : 1
98
102
  end
99
103
 
100
104
  # Before handing off to an older package (a downgrade), strip Plastic's own current hook
package/scripts/update.rb CHANGED
@@ -62,6 +62,8 @@ class Update < InstallerCore
62
62
  warn "No published version on the #{requested} channel."
63
63
  return 1
64
64
  when :ok
65
+ return 3 if refuse_incompatible_store_layout(res[:target])
66
+
65
67
  if res[:kind] == :cross_bleeding && !confirm_bleeding(iv, res[:target], argv)
66
68
  puts "Aborted."
67
69
  return 1
@@ -235,10 +237,14 @@ class Update < InstallerCore
235
237
  # the target version's install verb, recording the ledger action as `update`. On
236
238
  # success, commits the re-synced core files and clears the update-check cache
237
239
  # (former update skill, lines 124-125).
238
- def perform_switch(target, agent_flags, switch_runner: ->(cmd) { system(*cmd) })
240
+ def perform_switch(target, agent_flags, switch_runner: ->(cmd) { system({"PLASTIC_PACKAGE_ROOT" => nil}, *cmd) })
239
241
  cmd = ["npx", "#{PKG}@#{target}", "install", "--reinstall", "--ledger-action", "update", *agent_flags]
240
242
  puts " $ #{cmd.join(" ")}"
241
243
  return 1 unless switch_runner.call(cmd)
244
+ unless installed_version == target
245
+ warn "Update did not install #{target}; installed version is #{installed_version.inspect}."
246
+ return 1
247
+ end
242
248
 
243
249
  commit_core_files(target)
244
250
  clear_update_check_cache
@@ -1,10 +1,10 @@
1
1
  ## Numbered Decision Tables
2
2
 
3
3
  The shared procedure for collecting owner rulings during any stage (Why, How, Exec).
4
- Any stage skill that needs the owner to choose between options or rule on a batch of
5
- open questions follows this procedure instead of improvising its own format.
4
+ An agent that needs the owner to choose between options or rule on a batch of open
5
+ questions follows this procedure instead of improvising its own format.
6
6
 
7
- Read this when a stage skill's own text says to.
7
+ The installer copies this file to `~/.plastic/_decision-tables.md`.
8
8
 
9
9
  ### The procedure
10
10