@zalom/plastic 1.6.0 → 1.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -1266,7 +1266,7 @@ class Doctor
1266
1266
 
1267
1267
  # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
1268
1268
  # e.g. an old-name copy after a rename; intent 158a AC15)
1269
- stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic-manifest.json"))
1269
+ stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic", "manifest.json"))
1270
1270
  checks << stray_check if stray_check
1271
1271
 
1272
1272
  checks
@@ -1732,14 +1732,16 @@ class Doctor
1732
1732
  "#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
1733
1733
  "#{tilde(agent_version_path)}: #{agent_version}",
1734
1734
  ],
1735
- fixable: false
1735
+ fixable: true,
1736
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
1736
1737
  )
1737
1738
  end
1738
1739
  else
1739
1740
  checks << check(
1740
1741
  category: "core_files", name: "version_match", status: "warn",
1741
1742
  message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
1742
- fixable: false
1743
+ fixable: true,
1744
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
1743
1745
  )
1744
1746
  end
1745
1747
  end
@@ -1944,8 +1946,7 @@ class Doctor
1944
1946
  # Verify, for BOTH the global manifest and the agent-side manifest, that every
1945
1947
  # file listed exists and its current SHA256 matches the recorded hash.
1946
1948
  # - GLOBAL manifest: <plastic_home>/manifest.json
1947
- # - AGENT-side manifest: claude -> <dir>/plastic/manifest.json
1948
- # other -> <dir>/plastic-manifest.json
1949
+ # - AGENT-side manifest (every agent, intent 210 D2): <dir>/plastic/manifest.json
1949
1950
  # Manifest format: { "version", "created", "files": { abs_path => sha256 } }.
1950
1951
  # A missing manifest is a fail; any missing/mismatched listed file is a fail;
1951
1952
  # otherwise a single pass per manifest.
@@ -1956,11 +1957,7 @@ class Doctor
1956
1957
  checks << verify_manifest(global_manifest, "global")
1957
1958
 
1958
1959
  agent_dir = agents[agent_key][:dir]
1959
- agent_manifest = if agent_key == "claude"
1960
- File.join(agent_dir, "plastic", "manifest.json")
1961
- else
1962
- File.join(agent_dir, "plastic-manifest.json")
1963
- end
1960
+ agent_manifest = File.join(agent_dir, "plastic", "manifest.json")
1964
1961
  checks << verify_manifest(agent_manifest, "agent")
1965
1962
 
1966
1963
  checks
@@ -2017,6 +2014,32 @@ class Doctor
2017
2014
  end
2018
2015
  end
2019
2016
 
2017
+ # install_integrity (intent 210, D4): FULL tier only, warn-only, never writes. For each
2018
+ # installed agent, re-hash its manifest-listed files against disk and WARN on drift
2019
+ # (a hand-edit is legitimate; this is advisory, unlike the core-tier binary manifest_sync
2020
+ # gate). PASS when clean. `agents` here is doctor's own Hash keyed by agent key.
2021
+ def check_install_integrity
2022
+ checks = []
2023
+ agents.each do |key, config|
2024
+ manifest_path = File.join(config[:dir], "plastic", "manifest.json")
2025
+ next unless File.exist?(manifest_path)
2026
+ data = read_json_safe(manifest_path)
2027
+ files = data.is_a?(Hash) ? (data["files"] || {}) : {}
2028
+ next if files.empty?
2029
+ drifted = files.reject { |f, h| File.exist?(f) && Digest::SHA256.file(f).hexdigest == h }
2030
+ checks << if drifted.empty?
2031
+ check(category: "install_integrity", name: "#{key}_integrity", status: "pass",
2032
+ message: "#{config[:name]}: all #{files.size} tracked file(s) match the manifest")
2033
+ else
2034
+ check(category: "install_integrity", name: "#{key}_integrity", status: "warn",
2035
+ message: "#{config[:name]}: #{drifted.size} tracked file(s) differ from the manifest (drift may be deliberate)",
2036
+ details: drifted.keys.map { |f| tilde(f) }, fixable: true,
2037
+ fix_hint: "Re-sync if unintended: npx @zalom/plastic@latest install --reinstall --#{key}")
2038
+ end
2039
+ end
2040
+ checks
2041
+ end
2042
+
2020
2043
  # --- Check category 5: Project stores ---
2021
2044
 
2022
2045
  def check_project_stores
@@ -2454,6 +2477,7 @@ class Doctor
2454
2477
  all_checks += check_qmd
2455
2478
  all_checks += check_done_signals
2456
2479
  all_checks += check_skill_lint
2480
+ all_checks += check_install_integrity
2457
2481
 
2458
2482
  summarize(all_checks, agent_key)
2459
2483
  end
@@ -79,11 +79,24 @@ class Install < InstallerCore
79
79
  bootstrap if fresh
80
80
  apply_config_flags(argv)
81
81
 
82
- results = selected.map { |key| install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall) }
82
+ results = selected.map do |key|
83
+ result = transactional_install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall)
84
+ result[:key] = key
85
+ result
86
+ end
83
87
  results += already_registered.map { |key| already_registered_result(key) }
84
88
 
85
89
  action = ledger_action || (fresh ? "install" : "reinstall")
86
- ledger_append(version, action)
90
+ # One ledger row per successfully-synced agent, carrying its harness (intent 210,
91
+ # G5). A run where nothing agent-specific succeeded (or selected was empty) still
92
+ # gets a single core-only row, so the version/action event is never silently
93
+ # dropped from the ledger.
94
+ synced = results.select { |r| r[:success] && r[:key] }
95
+ if synced.any?
96
+ synced.each { |r| ledger_append(version, action, harness: r[:key]) }
97
+ else
98
+ ledger_append(version, action)
99
+ end
87
100
 
88
101
  print_results(results, fresh ? :install : :reinstall)
89
102
  results
@@ -152,6 +165,8 @@ class Install < InstallerCore
152
165
  end
153
166
  end
154
167
 
168
+ print_agent_summary(results)
169
+
155
170
  installed = results.select { |r| r[:success] }
156
171
  return unless installed.any?
157
172
 
@@ -164,6 +179,21 @@ class Install < InstallerCore
164
179
  print_codex_hook_trust_reminder(installed)
165
180
  end
166
181
 
182
+ # Per-agent transaction summary (intent 210, D3): agent | from -> to | ok/failed.
183
+ # Skipped for a run with only one result, where the line above already says it all.
184
+ def print_agent_summary(results)
185
+ return if results.size <= 1
186
+
187
+ puts "\n Agent From \u{2192} To Result"
188
+ puts " ----- ----------- ------"
189
+ results.each do |r|
190
+ from = r[:from_version] || "-"
191
+ to = r[:to_version] || "-"
192
+ status = r[:already_registered] ? "skipped" : (r[:success] ? "ok" : "failed")
193
+ puts format(" %-14s %-16s %s", r[:agent], "#{from} \u{2192} #{to}", status)
194
+ end
195
+ end
196
+
167
197
  # Codex hooks are installed but INERT until a human reviews and trusts each
168
198
  # hook definition via /hooks (intent 198, Decision D2); Codex keys trust to
169
199
  # the hook's current command hash, so a future release that changes a hook
@@ -133,11 +133,15 @@ class InstallerCore
133
133
  end
134
134
 
135
135
  # Append a single immutable entry. Opens in append mode; never rewrites prior lines.
136
- # action ∈ { install, reinstall, update, downgrade }.
137
- def ledger_append(entry_version, action)
136
+ # action ∈ { install, reinstall, update, downgrade }. `harness` (intent 210, G5) names
137
+ # which agent this row records a sync for (claude/codex/hermes); optional so a
138
+ # core-only or pre-210 caller still writes a valid, readable row. Readers must stay
139
+ # tolerant of legacy rows that carry no "harness" key at all.
140
+ def ledger_append(entry_version, action, harness: nil)
138
141
  FileUtils.mkdir_p(plastic_home)
139
- line = JSON.generate("version" => entry_version, "action" => action, "at" => Time.now.utc.iso8601)
140
- File.open(ledger_path, "a") { |f| f.puts(line) }
142
+ entry = { "version" => entry_version, "action" => action, "at" => Time.now.utc.iso8601 }
143
+ entry["harness"] = harness if harness
144
+ File.open(ledger_path, "a") { |f| f.puts(JSON.generate(entry)) }
141
145
  end
142
146
 
143
147
  def ledger_read
@@ -414,11 +418,20 @@ class InstallerCore
414
418
 
415
419
  # --- Agent adapters ---
416
420
 
417
- def manifest_path_for(key, config)
418
- case key
419
- when "claude" then File.join(config[:dir], "plastic", "manifest.json")
420
- else File.join(config[:dir], "plastic-manifest.json")
421
- end
421
+ # Uniform per-agent install record dir: <agent-dir>/plastic/ holds VERSION and
422
+ # manifest.json for every agent (intent 210, D2). Claude already used this; Codex
423
+ # and Hermes are migrated onto it so one rule covers all agents and doctor's existing
424
+ # version_match probe (<dir>/plastic/VERSION) lands on it.
425
+ def record_dir_for(config)
426
+ File.join(config[:dir], "plastic")
427
+ end
428
+
429
+ def legacy_manifest_path_for(config)
430
+ File.join(config[:dir], "plastic-manifest.json")
431
+ end
432
+
433
+ def manifest_path_for(_key, config)
434
+ File.join(record_dir_for(config), "manifest.json")
422
435
  end
423
436
 
424
437
  def manifest_files(manifest_path)
@@ -445,6 +458,18 @@ class InstallerCore
445
458
  !manifest_files(manifest_path_for(key, config)).empty?
446
459
  end
447
460
 
461
+ # Agent keys whose per-agent record exists (intent 210, D2): folder-with-VERSION =
462
+ # registered. Reads the record, never a written config list. Fail-open: an unreadable
463
+ # record is simply "not installed" here (doctor reports integrity separately).
464
+ def installed_agents
465
+ agents.select { |a| File.exist?(File.join(record_dir_for(a), "VERSION")) }.map { |a| a[:key] }
466
+ end
467
+
468
+ def agent_version_for(config)
469
+ path = File.join(record_dir_for(config), "VERSION")
470
+ File.exist?(path) ? File.read(path).strip : nil
471
+ end
472
+
448
473
  def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
449
474
  config = agent_config(key)
450
475
  return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
@@ -469,7 +494,12 @@ class InstallerCore
469
494
 
470
495
  # Capture the prior manifest so we can prune files that no longer ship
471
496
  # (renamed/removed skills) after a re-copy. This gives leftover-free updates.
497
+ # Union in the legacy flat manifest's files too (intent 210, Codex migration): an
498
+ # agent still on the pre-migration <dir>/plastic-manifest.json record tracked files
499
+ # the new per-agent manifest never lists, so without the union prune would miss them.
472
500
  old_files = manifest_files(manifest_path_for(key, config))
501
+ legacy_path = legacy_manifest_path_for(config)
502
+ old_files |= manifest_files(legacy_path) if File.exist?(legacy_path) && legacy_path != manifest_path_for(key, config)
473
503
 
474
504
  result = case key
475
505
  when "claude" then install_claude(config, force, argv: argv, input: input, reinstall: reinstall)
@@ -480,6 +510,90 @@ class InstallerCore
480
510
  new_files = manifest_files(manifest_path_for(key, config))
481
511
  pruned = prune_removed_files(old_files - new_files)
482
512
  result[:pruned] = pruned if pruned.positive?
513
+
514
+ # The legacy manifest is fully superseded once the new one is written; delete it so
515
+ # the migration is one-shot (intent 210, D2).
516
+ if File.exist?(legacy_path) && legacy_path != manifest_path_for(key, config)
517
+ File.delete(legacy_path)
518
+ end
519
+
520
+ result
521
+ end
522
+
523
+ # --- Per-agent transaction with auto-restore (intent 210, D3) ---
524
+
525
+ def backup_dir_for(config)
526
+ File.join(plastic_home, "backups", config[:key])
527
+ end
528
+
529
+ # Snapshot the agent's currently manifest-listed files, PLUS the manifest itself (so
530
+ # a restore puts the manifest and the files it describes back in sync; otherwise a
531
+ # restored file set would be checked against the just-written NEW manifest and fail
532
+ # verification all over again), into the backup dir, mirroring their absolute paths
533
+ # under it. Replaces any prior snapshot (one snapshot kept, D3). Returns the list of
534
+ # (source_abs) files snapshotted.
535
+ def snapshot_agent(config)
536
+ dir = backup_dir_for(config)
537
+ FileUtils.rm_rf(dir)
538
+ manifest_path = manifest_path_for(config[:key], config)
539
+ files = manifest_files(manifest_path)
540
+ files |= [manifest_path] if File.exist?(manifest_path)
541
+ files.each do |f|
542
+ next unless File.exist?(f)
543
+ dest = File.join(dir, f) # f is absolute; File.join keeps the tree distinct per agent
544
+ FileUtils.mkdir_p(File.dirname(dest))
545
+ FileUtils.cp(f, dest)
546
+ end
547
+ files
548
+ end
549
+
550
+ # Restore the snapshot back over the live tree for this agent (auto-restore, D3/D4).
551
+ def restore_agent(config)
552
+ dir = backup_dir_for(config)
553
+ Dir.glob(File.join(dir, "**", "*")).each do |src|
554
+ next unless File.file?(src)
555
+ dest = src.sub(dir, "")
556
+ FileUtils.mkdir_p(File.dirname(dest))
557
+ FileUtils.cp(src, dest)
558
+ end
559
+ end
560
+
561
+ # True when every file listed in the agent manifest exists on disk with the recorded
562
+ # hash. A missing or mismatched listed file is the falsifiable failure that triggers
563
+ # auto-restore (intent 210, G4/G8).
564
+ def verify_agent_manifest(config)
565
+ path = manifest_path_for(config[:key], config)
566
+ return false unless File.exist?(path)
567
+ data = JSON.parse(File.read(path)) rescue {}
568
+ files = data["files"] || {}
569
+ return false if files.empty?
570
+ files.all? { |f, h| File.exist?(f) && Digest::SHA256.file(f).hexdigest == h }
571
+ end
572
+
573
+ # Per-agent transaction (intent 210, D3): snapshot -> apply -> verify -> auto-restore on
574
+ # failure. Forward-fix: a failure here never touches other agents. On a fresh install
575
+ # (no prior manifest) verify-failure prunes the partial write instead of restoring.
576
+ def transactional_install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
577
+ config = agent_config(key)
578
+ return { agent: key, success: false, reason: "Unknown agent" } unless config
579
+
580
+ from_version = agent_version_for(config)
581
+ had_record = !manifest_files(manifest_path_for(key, config)).empty?
582
+ snapshot_agent(config) if had_record
583
+
584
+ result = install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall)
585
+
586
+ if result[:success] && !verify_agent_manifest(config)
587
+ if had_record
588
+ restore_agent(config)
589
+ result = { agent: config[:name], success: false, reason: "verify failed - restored prior snapshot" }
590
+ else
591
+ prune_removed_files(manifest_files(manifest_path_for(key, config)))
592
+ result = { agent: config[:name], success: false, reason: "verify failed - partial install pruned" }
593
+ end
594
+ end
595
+ result[:from_version] = from_version
596
+ result[:to_version] = agent_version_for(config)
483
597
  result
484
598
  end
485
599
 
@@ -559,6 +673,8 @@ class InstallerCore
559
673
  end
560
674
 
561
675
  def install_codex(config, force)
676
+ FileUtils.mkdir_p(record_dir_for(config))
677
+
562
678
  installed = []
563
679
  skills_source = File.join(package_root, "skills")
564
680
  skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
@@ -578,7 +694,13 @@ class InstallerCore
578
694
  # (stripped surgically on uninstall), same treatment as AGENTS.md.
579
695
  merge_codex_hooks(File.join(config[:home_dir], "hooks.json"))
580
696
 
581
- write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
697
+ # Uniform per-agent record (intent 210, D2): write VERSION alongside the manifest,
698
+ # the same shape install_claude already writes.
699
+ version_file = File.join(record_dir_for(config), "VERSION")
700
+ File.write(version_file, "#{version}\n")
701
+ installed << version_file
702
+
703
+ write_manifest(installed, manifest_path_for("codex", config))
582
704
  { agent: config[:name], success: true, files: installed.size }
583
705
  end
584
706
 
@@ -723,13 +845,21 @@ class InstallerCore
723
845
  end
724
846
 
725
847
  def install_hermes(config, force)
848
+ FileUtils.mkdir_p(record_dir_for(config))
849
+
726
850
  installed = []
727
851
  skills_source = File.join(package_root, "skills")
728
852
  skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
729
853
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
730
854
  installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
731
855
 
732
- write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
856
+ # Uniform per-agent record (intent 210, D2): write VERSION alongside the manifest,
857
+ # the same shape install_claude already writes.
858
+ version_file = File.join(record_dir_for(config), "VERSION")
859
+ File.write(version_file, "#{version}\n")
860
+ installed << version_file
861
+
862
+ write_manifest(installed, manifest_path_for("hermes", config))
733
863
  { agent: config[:name], success: true, files: installed.size }
734
864
  end
735
865
 
package/scripts/update.rb CHANGED
@@ -42,8 +42,22 @@ class Update < InstallerCore
42
42
 
43
43
  case res[:status]
44
44
  when :up_to_date
45
- puts "\u{2705} Plastic v#{iv} is already up to date on the #{channel_for(iv)} channel."
46
- return 0
45
+ targeted = target_agent_keys(argv)
46
+ stale = agents_needing_sync(target: iv, agent_versions: agent_versions_for(targeted))
47
+ if stale.empty?
48
+ puts "\u{2705} Plastic v#{iv} is already up to date on the #{channel_for(iv)} channel."
49
+ return 0
50
+ end
51
+ # Same-version repair (intent 210, D1/AC4): the core is current but a targeted
52
+ # agent's own record is stale or missing (e.g. a harness added after the last
53
+ # sync). Re-sync at the SAME version instead of the clean no-op above.
54
+ puts "\u{1f527} Plastic core v#{iv} is current; repairing stale agent(s): #{stale.join(", ")}"
55
+ exit_code = perform_switch(iv, agent_args(argv))
56
+ if exit_code == 0
57
+ announce_pending_config_asks(agent_key: primary_agent_key(argv))
58
+ run_post_update_doctor(full: argv.include?("--full-doctor"), synced_agents: targeted)
59
+ end
60
+ exit_code
47
61
  when :unknown_channel
48
62
  warn "No published version on the #{requested} channel."
49
63
  return 1
@@ -56,12 +70,19 @@ class Update < InstallerCore
56
70
  exit_code = perform_switch(res[:target], agent_args(argv))
57
71
  if exit_code == 0
58
72
  announce_pending_config_asks(agent_key: primary_agent_key(argv))
59
- run_post_update_doctor(full: argv.include?("--full-doctor"))
73
+ run_post_update_doctor(full: argv.include?("--full-doctor"), synced_agents: target_agent_keys(argv))
60
74
  end
61
75
  exit_code
62
76
  end
63
77
  end
64
78
 
79
+ # Given the resolved target version and a { key => installed_version_or_nil } map for the
80
+ # agents this update targets, return the keys that still need a sync: version behind the
81
+ # target, or missing (nil). All-current returns []. Pure; unit-tested (intent 210, G3).
82
+ def agents_needing_sync(target:, agent_versions:)
83
+ agent_versions.select { |_k, v| v.nil? || semver_gt?(target, v) }.keys
84
+ end
85
+
65
86
  # Print any pending config question(s) straight to stdout, right after a
66
87
  # successful perform_switch (the moment the NEW config_asks.yml and
67
88
  # write-config just landed on disk via the target versions own
@@ -99,22 +120,29 @@ class Update < InstallerCore
99
120
  out.puts " could not check config asks: #{e.message}"
100
121
  end
101
122
 
102
- # Run doctor after a successful update and print a human-readable summary.
103
- # Defaults to the fast core tier (agent registration + core files + manifest
104
- # sync, binary pass|fail, no store walk) so a newcomer's first post-update
123
+ # Run doctor after a successful update and print a human-readable summary, once per
124
+ # synced agent (intent 210, C5: a Codex-only or --all update must not always report
125
+ # only claude). Defaults to the fast core tier (agent registration + core files +
126
+ # manifest sync, binary pass|fail, no store walk) so a newcomer's first post-update
105
127
  # run is not buried in convention warns they cannot act on. `full: true`
106
128
  # (via `--full-doctor`) runs the complete store walk instead. Informational
107
129
  # only: does not raise and does not affect the update's exit code. Accepts
108
- # injected `doctor` and `out` for hermetic unit tests.
109
- def run_post_update_doctor(doctor: nil, out: $stdout, full: false)
130
+ # injected `doctor` and `out` for hermetic unit tests. Returns the single result hash
131
+ # when exactly one agent was checked (matches the pre-210 return shape), else an
132
+ # array of per-agent result hashes.
133
+ def run_post_update_doctor(doctor: nil, out: $stdout, full: false, synced_agents: ["claude"])
110
134
  doctor ||= Doctor.new
135
+ keys = synced_agents.nil? || synced_agents.empty? ? ["claude"] : synced_agents
111
136
  out.puts full ? "\nRunning full doctor after update..." : "\nRunning core doctor after update..."
112
- result = full ? doctor.run_checks("claude") : doctor.run_core_checks("claude")
113
- s = result[:summary]
114
- out.puts " Doctor status: #{result[:status]} " \
115
- "(pass: #{s[:pass]}, warn: #{s[:warn]}, fail: #{s[:fail]}, total: #{s[:total]})"
116
- out.puts " Run /plastic-doctor for details." unless result[:status] == "pass"
117
- result
137
+ results = keys.map do |key|
138
+ result = full ? doctor.run_checks(key) : doctor.run_core_checks(key)
139
+ s = result[:summary]
140
+ out.puts " [#{key}] Doctor status: #{result[:status]} " \
141
+ "(pass: #{s[:pass]}, warn: #{s[:warn]}, fail: #{s[:fail]}, total: #{s[:total]})"
142
+ out.puts " Run /plastic-doctor for details." unless result[:status] == "pass"
143
+ result
144
+ end
145
+ keys.size == 1 ? results.first : results
118
146
  rescue StandardError => e
119
147
  # Non-blocking: a crash here (e.g. malformed file in the real store) must not
120
148
  # undo or fail an update that already succeeded. Report and move on.
@@ -152,11 +180,30 @@ class Update < InstallerCore
152
180
  nil
153
181
  end
154
182
 
155
- # Agent flags to pass through to the delegated install (default --claude).
183
+ # Agent keys this invocation targets (intent 210, D1/G3): explicit flags in argv, or
184
+ # --all, or, when no flag was given at all, every currently-installed agent (never a
185
+ # hardcoded Claude-only default). Single source of truth for both agent_args (the
186
+ # argv fragment forwarded to the delegated install) and the same-version repair check.
187
+ def target_agent_keys(argv)
188
+ return agents.map { |a| a[:key] } if argv.include?("--all")
189
+
190
+ explicit = agents.select { |a| argv.include?(a[:flag]) }.map { |a| a[:key] }
191
+ return explicit unless explicit.empty?
192
+
193
+ installed = installed_agents
194
+ installed.empty? ? ["claude"] : installed
195
+ end
196
+
197
+ # Agent flags to pass through to the delegated install. No-flag resolves to every
198
+ # installed agent's flag, not a hardcoded ["--claude"] (intent 210, AC3).
156
199
  def agent_args(argv)
157
- flags = agents.map { |a| a[:flag] }.select { |f| argv.include?(f) }
158
- flags << "--all" if argv.include?("--all")
159
- flags.empty? ? ["--claude"] : flags
200
+ target_agent_keys(argv).map { |k| agents.find { |a| a[:key] == k }[:flag] }
201
+ end
202
+
203
+ # { key => installed_version_or_nil } for the given agent keys, read from each
204
+ # agent's own record (intent 210, D1).
205
+ def agent_versions_for(keys)
206
+ keys.each_with_object({}) { |k, h| h[k] = agent_version_for(agent_config(k)) }
160
207
  end
161
208
 
162
209
  # The single agent key this update is installing for, for config_asks
@@ -47,15 +47,25 @@ first (or `npx -y @zalom/plastic@latest install --claude` directly).
47
47
  ### Step 1: Run the update
48
48
 
49
49
  ```bash
50
- npx -y @zalom/plastic@<channel> update --claude
50
+ npx -y @zalom/plastic@<channel> update
51
51
  # channel switch: append --beta / --latest / --alpha
52
- # other agents: append --codex / --hermes / --all
52
+ # to target a specific harness instead of every installed one: --claude / --codex / --hermes / --all
53
53
  ```
54
54
 
55
- `bunx -y @zalom/plastic@<channel> update --claude` works as a fallback if `npx` is
56
- unavailable. The command prints the transition (`vX -> vY`) or "already up to date", runs
57
- a post-update doctor summary, and records the move in the append-only
58
- `~/.plastic/versions.json` ledger.
55
+ `bunx -y @zalom/plastic@<channel> update` works as a fallback if `npx` is unavailable.
56
+
57
+ With no harness flag, `update` targets **every currently-installed harness** (read from
58
+ each agent's own `<agent-dir>/plastic/VERSION`), never Claude alone. Pass an explicit flag
59
+ only to target one harness specifically.
60
+
61
+ If the core is already on the target version but a targeted harness's own record is stale
62
+ or missing (for example a harness added after the last sync), `update` still re-syncs that
63
+ harness at the same version instead of reporting a clean no-op (same-version repair).
64
+
65
+ The command prints the transition (`vX -> vY`) or "already up to date", a per-agent
66
+ transaction summary when more than one harness synced, runs a post-update doctor summary
67
+ per synced harness, and records one `versions.json` ledger row per synced harness (each
68
+ row's `harness` field names which one).
59
69
 
60
70
  ### Step 2: Relay any pending config question(s) the update printed
61
71
 
@@ -94,7 +104,7 @@ summary:
94
104
 
95
105
  ```
96
106
  Plastic update (<channel>)
97
- Command: npx -y @zalom/plastic@<channel> update --claude <flags>
107
+ Command: npx -y @zalom/plastic@<channel> update <flags>
98
108
  Version: <before> -> <after>
99
109
  Doctor: <relayed summary, or "all clear">
100
110
  ```