@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 +1 -1
- package/scripts/doctor.rb +34 -10
- package/scripts/install.rb +32 -2
- package/scripts/lib/installer_core.rb +141 -11
- package/scripts/update.rb +65 -18
- package/skills/update/SKILL.md +17 -7
package/package.json
CHANGED
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
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
package/scripts/install.rb
CHANGED
|
@@ -79,11 +79,24 @@ class Install < InstallerCore
|
|
|
79
79
|
bootstrap if fresh
|
|
80
80
|
apply_config_flags(argv)
|
|
81
81
|
|
|
82
|
-
results = selected.map
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
#
|
|
104
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
package/skills/update/SKILL.md
CHANGED
|
@@ -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
|
|
50
|
+
npx -y @zalom/plastic@<channel> update
|
|
51
51
|
# channel switch: append --beta / --latest / --alpha
|
|
52
|
-
#
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
107
|
+
Command: npx -y @zalom/plastic@<channel> update <flags>
|
|
98
108
|
Version: <before> -> <after>
|
|
99
109
|
Doctor: <relayed summary, or "all clear">
|
|
100
110
|
```
|