@zalom/plastic 1.5.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 +110 -11
- package/scripts/install.rb +32 -2
- package/scripts/lib/agent_models.rb +25 -0
- package/scripts/lib/installer_core.rb +154 -17
- package/scripts/update.rb +65 -18
- package/skills/update/SKILL.md +17 -7
package/package.json
CHANGED
package/scripts/doctor.rb
CHANGED
|
@@ -14,6 +14,7 @@ require "yaml"
|
|
|
14
14
|
require "time"
|
|
15
15
|
require "date"
|
|
16
16
|
require "digest"
|
|
17
|
+
require "rubygems"
|
|
17
18
|
|
|
18
19
|
require_relative "lib/qmd_sync"
|
|
19
20
|
require_relative "lib/intent_validator"
|
|
@@ -71,13 +72,32 @@ class Doctor
|
|
|
71
72
|
doctor.rb
|
|
72
73
|
].freeze
|
|
73
74
|
|
|
75
|
+
# The apply_patch PreToolUse veto only fires from Codex v0.123.0 onward
|
|
76
|
+
# (PR #18391, "emit hooks for apply_patch edits"). Below it the veto
|
|
77
|
+
# silently no-ops (intent 184).
|
|
78
|
+
CODEX_HOOKS_FLOOR = "0.123.0"
|
|
79
|
+
|
|
74
80
|
attr_reader :plastic_home, :agents
|
|
75
81
|
|
|
82
|
+
# Testable shell-out default, mirroring QmdSync.default_runner: a real
|
|
83
|
+
# Open3.capture3 call in production, swappable for a fake in tests so no
|
|
84
|
+
# test needs a real codex binary or a PATH mutation.
|
|
85
|
+
def self.default_runner
|
|
86
|
+
lambda do |args|
|
|
87
|
+
require "open3"
|
|
88
|
+
out, _err, status = Open3.capture3("codex", *args)
|
|
89
|
+
[out, status.success?]
|
|
90
|
+
rescue Errno::ENOENT
|
|
91
|
+
["", false] # codex not on PATH: undetectable, fail open
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
76
95
|
def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS,
|
|
77
|
-
bookend_amnesty: LEGACY_BOOKEND_AMNESTY)
|
|
96
|
+
bookend_amnesty: LEGACY_BOOKEND_AMNESTY, runner: Doctor.default_runner)
|
|
78
97
|
@plastic_home = plastic_home
|
|
79
98
|
@agents = agents
|
|
80
99
|
@bookend_amnesty = bookend_amnesty
|
|
100
|
+
@runner = runner
|
|
81
101
|
end
|
|
82
102
|
|
|
83
103
|
# --- Flag parsing ---
|
|
@@ -1246,7 +1266,7 @@ class Doctor
|
|
|
1246
1266
|
|
|
1247
1267
|
# stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
|
|
1248
1268
|
# e.g. an old-name copy after a rename; intent 158a AC15)
|
|
1249
|
-
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"))
|
|
1250
1270
|
checks << stray_check if stray_check
|
|
1251
1271
|
|
|
1252
1272
|
checks
|
|
@@ -1300,6 +1320,7 @@ class Doctor
|
|
|
1300
1320
|
checks << codex_hooks_implemented_check(config)
|
|
1301
1321
|
checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
|
|
1302
1322
|
codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
|
|
1323
|
+
codex_version_floor_check(config).tap { |c| checks << c if c }
|
|
1303
1324
|
|
|
1304
1325
|
checks
|
|
1305
1326
|
end
|
|
@@ -1555,6 +1576,60 @@ class Doctor
|
|
|
1555
1576
|
)
|
|
1556
1577
|
end
|
|
1557
1578
|
|
|
1579
|
+
# Codex version-floor advisory (intent 184): READ ONLY. The apply_patch PreToolUse
|
|
1580
|
+
# veto (scripts/lib/hook_registry.rb, scripts/codex-hook) only fires from Codex
|
|
1581
|
+
# v0.123.0 (PR #18391); below it the veto silently no-ops. version.json is Codex's
|
|
1582
|
+
# update-checker cache and holds no installed version, so the only install-method-
|
|
1583
|
+
# agnostic source is `codex --version`, shelled out through the injected runner.
|
|
1584
|
+
# Four honest branches (intent 208, no pass-by-construction): absent home -> nil;
|
|
1585
|
+
# present but undetectable -> distinct warn; below floor -> warn; at/above -> pass.
|
|
1586
|
+
def codex_version_floor_check(config)
|
|
1587
|
+
return nil unless File.exist?(config[:home_dir])
|
|
1588
|
+
|
|
1589
|
+
stdout, ok = @runner.call(["--version"])
|
|
1590
|
+
version = ok ? codex_version_from_output(stdout) : nil
|
|
1591
|
+
parsed = version && safe_version(version)
|
|
1592
|
+
|
|
1593
|
+
if parsed.nil?
|
|
1594
|
+
return check(
|
|
1595
|
+
category: "agent_registration", name: "codex_version_floor", status: "warn",
|
|
1596
|
+
message: "Could not determine the installed Codex version (`codex --version` did not " \
|
|
1597
|
+
"return a parseable version); Plastic cannot confirm the apply_patch hooks " \
|
|
1598
|
+
"floor v#{CODEX_HOOKS_FLOOR} is met, so its gate may silently no-op",
|
|
1599
|
+
fixable: false,
|
|
1600
|
+
fix_hint: "Ensure `codex` is on PATH and `codex --version` >= #{CODEX_HOOKS_FLOOR}"
|
|
1601
|
+
)
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
if parsed < safe_version(CODEX_HOOKS_FLOOR)
|
|
1605
|
+
return check(
|
|
1606
|
+
category: "agent_registration", name: "codex_version_floor", status: "warn",
|
|
1607
|
+
message: "Installed Codex #{version} predates v#{CODEX_HOOKS_FLOOR}; the apply_patch " \
|
|
1608
|
+
"PreToolUse veto only exists from v#{CODEX_HOOKS_FLOOR} (PR #18391), so Plastic's " \
|
|
1609
|
+
"gate silently no-ops on this install",
|
|
1610
|
+
fixable: false,
|
|
1611
|
+
fix_hint: "Upgrade Codex to v#{CODEX_HOOKS_FLOOR} or newer with your install method " \
|
|
1612
|
+
"(npm i -g @openai/codex, mise, homebrew, or cargo)"
|
|
1613
|
+
)
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
check(
|
|
1617
|
+
category: "agent_registration", name: "codex_version_floor", status: "pass",
|
|
1618
|
+
message: "Codex #{version} meets the apply_patch hooks floor v#{CODEX_HOOKS_FLOOR}"
|
|
1619
|
+
)
|
|
1620
|
+
end
|
|
1621
|
+
|
|
1622
|
+
def codex_version_from_output(stdout)
|
|
1623
|
+
m = stdout.to_s.match(/(\d+\.\d+\.\d+(?:[.\-+][0-9A-Za-z.\-+]*)?)/)
|
|
1624
|
+
m && m[1]
|
|
1625
|
+
end
|
|
1626
|
+
|
|
1627
|
+
def safe_version(str)
|
|
1628
|
+
Gem::Version.new(str.to_s)
|
|
1629
|
+
rescue ArgumentError
|
|
1630
|
+
nil
|
|
1631
|
+
end
|
|
1632
|
+
|
|
1558
1633
|
# --- Check category 4: Core files ---
|
|
1559
1634
|
|
|
1560
1635
|
def check_core_files(agent_key)
|
|
@@ -1657,14 +1732,16 @@ class Doctor
|
|
|
1657
1732
|
"#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
|
|
1658
1733
|
"#{tilde(agent_version_path)}: #{agent_version}",
|
|
1659
1734
|
],
|
|
1660
|
-
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"
|
|
1661
1737
|
)
|
|
1662
1738
|
end
|
|
1663
1739
|
else
|
|
1664
1740
|
checks << check(
|
|
1665
1741
|
category: "core_files", name: "version_match", status: "warn",
|
|
1666
1742
|
message: "Agent-side VERSION file not found at #{tilde(agent_version_path)}",
|
|
1667
|
-
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"
|
|
1668
1745
|
)
|
|
1669
1746
|
end
|
|
1670
1747
|
end
|
|
@@ -1869,8 +1946,7 @@ class Doctor
|
|
|
1869
1946
|
# Verify, for BOTH the global manifest and the agent-side manifest, that every
|
|
1870
1947
|
# file listed exists and its current SHA256 matches the recorded hash.
|
|
1871
1948
|
# - GLOBAL manifest: <plastic_home>/manifest.json
|
|
1872
|
-
# - AGENT-side manifest
|
|
1873
|
-
# other -> <dir>/plastic-manifest.json
|
|
1949
|
+
# - AGENT-side manifest (every agent, intent 210 D2): <dir>/plastic/manifest.json
|
|
1874
1950
|
# Manifest format: { "version", "created", "files": { abs_path => sha256 } }.
|
|
1875
1951
|
# A missing manifest is a fail; any missing/mismatched listed file is a fail;
|
|
1876
1952
|
# otherwise a single pass per manifest.
|
|
@@ -1881,11 +1957,7 @@ class Doctor
|
|
|
1881
1957
|
checks << verify_manifest(global_manifest, "global")
|
|
1882
1958
|
|
|
1883
1959
|
agent_dir = agents[agent_key][:dir]
|
|
1884
|
-
agent_manifest =
|
|
1885
|
-
File.join(agent_dir, "plastic", "manifest.json")
|
|
1886
|
-
else
|
|
1887
|
-
File.join(agent_dir, "plastic-manifest.json")
|
|
1888
|
-
end
|
|
1960
|
+
agent_manifest = File.join(agent_dir, "plastic", "manifest.json")
|
|
1889
1961
|
checks << verify_manifest(agent_manifest, "agent")
|
|
1890
1962
|
|
|
1891
1963
|
checks
|
|
@@ -1942,6 +2014,32 @@ class Doctor
|
|
|
1942
2014
|
end
|
|
1943
2015
|
end
|
|
1944
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
|
+
|
|
1945
2043
|
# --- Check category 5: Project stores ---
|
|
1946
2044
|
|
|
1947
2045
|
def check_project_stores
|
|
@@ -2379,6 +2477,7 @@ class Doctor
|
|
|
2379
2477
|
all_checks += check_qmd
|
|
2380
2478
|
all_checks += check_done_signals
|
|
2381
2479
|
all_checks += check_skill_lint
|
|
2480
|
+
all_checks += check_install_integrity
|
|
2382
2481
|
|
|
2383
2482
|
summarize(all_checks, agent_key)
|
|
2384
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
|
|
@@ -31,6 +31,10 @@ module AgentModels
|
|
|
31
31
|
# dispatched by the auto pipeline, not part of TIER_DEFAULTS. Claude-only for
|
|
32
32
|
# this release (generate_codex_agents skips both by name; the Codex advisor
|
|
33
33
|
# case is intent 186, not a permanent exclusion).
|
|
34
|
+
#
|
|
35
|
+
# Intent 186 DEFINES the advisor Codex pairing but keeps emission deferred: when the skip is
|
|
36
|
+
# lifted, plastic-advisor pairs to gpt-5.6-sol at xhigh (the deepest) and plastic-faux-advisor
|
|
37
|
+
# to gpt-5.6-terra at high (cheaper). Neither is in TIER_DEFAULTS and neither is auto-dispatched.
|
|
34
38
|
CONSULTATION_AGENTS = %w[plastic-advisor plastic-faux-advisor].freeze
|
|
35
39
|
|
|
36
40
|
# Codex reasoning-effort per tier alias (intent 102a). model_reasoning_effort is a
|
|
@@ -45,6 +49,21 @@ module AgentModels
|
|
|
45
49
|
"haiku" => "low"
|
|
46
50
|
}.freeze
|
|
47
51
|
|
|
52
|
+
# Codex model id per tier alias (intent 186). Codex has NO vendor alias layer: every model id
|
|
53
|
+
# is a literal versioned string that rots (gpt-5.2 / gpt-5.3-codex already deprecated), which is
|
|
54
|
+
# why 116 D1 / 102a Decision B refused to pin a raw id per role file. This resolves that by
|
|
55
|
+
# centralizing every id in ONE map: Plastic owns the alias, so per-role identity costs a single
|
|
56
|
+
# line to refresh on a Codex deprecation plus a Plastic release, and no per-role file carries a
|
|
57
|
+
# raw id. opus (deepest reasoning tier) -> the flagship Sol; sonnet (execution tier) -> the
|
|
58
|
+
# balanced Terra; haiku (lightest) -> the fast/cheap Luna. Paired with EFFORT_BY_ALIAS so
|
|
59
|
+
# reasoning roles get a stronger model AND higher effort than executors. This is a shipped
|
|
60
|
+
# DEFAULT, fully overridable via agents.models.codex.<name>.
|
|
61
|
+
CODEX_MODEL_BY_ALIAS = {
|
|
62
|
+
"opus" => "gpt-5.6-sol",
|
|
63
|
+
"sonnet" => "gpt-5.6-terra",
|
|
64
|
+
"haiku" => "gpt-5.6-luna"
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
48
67
|
module_function
|
|
49
68
|
|
|
50
69
|
# Pull { basename => model } out of a loaded config hash's `agents.models`
|
|
@@ -85,4 +104,10 @@ module AgentModels
|
|
|
85
104
|
def effort_for(value)
|
|
86
105
|
EFFORT_BY_ALIAS[value.to_s]
|
|
87
106
|
end
|
|
107
|
+
|
|
108
|
+
# The Codex model id for a Plastic tier alias, or nil for any value that is not one of the three
|
|
109
|
+
# shipped aliases (the caller then treats the value as a literal Codex model id, or omits it).
|
|
110
|
+
def codex_model_for(value)
|
|
111
|
+
CODEX_MODEL_BY_ALIAS[value.to_s]
|
|
112
|
+
end
|
|
88
113
|
end
|
|
@@ -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
|
|
|
@@ -613,8 +735,8 @@ class InstallerCore
|
|
|
613
735
|
end
|
|
614
736
|
|
|
615
737
|
# Render one repo agents/*.md into a deterministic Codex agent TOML document. Fixed field
|
|
616
|
-
# order (name, description,
|
|
617
|
-
# byte-identical (idempotency).
|
|
738
|
+
# order (name, description, the model field(s) from codex_model_fields, developer_instructions)
|
|
739
|
+
# so regenerate is byte-identical (idempotency).
|
|
618
740
|
def render_codex_agent_toml(source_path, override)
|
|
619
741
|
front, body = split_frontmatter(File.read(source_path))
|
|
620
742
|
name = (front["name"] || File.basename(source_path, ".md")).to_s
|
|
@@ -641,14 +763,21 @@ class InstallerCore
|
|
|
641
763
|
end
|
|
642
764
|
end
|
|
643
765
|
|
|
644
|
-
# The
|
|
645
|
-
#
|
|
646
|
-
#
|
|
766
|
+
# The model-selection line(s). A known tier alias (opus/sonnet/haiku) emits BOTH a `model` line
|
|
767
|
+
# (from AgentModels.codex_model_for, the intent-186 per-role Codex identity) and a
|
|
768
|
+
# model_reasoning_effort line, model first for deterministic byte-identical regenerate. Any other
|
|
769
|
+
# non-empty value is a literal Codex model id emitted verbatim as `model` only. Empty -> no line
|
|
770
|
+
# (the agent inherits the session default). If an alias somehow lacks a mapped model, the effort
|
|
771
|
+
# line still emits alone (backward-safe).
|
|
647
772
|
def codex_model_fields(effective)
|
|
648
773
|
return "" if effective.nil? || effective.to_s.empty?
|
|
649
774
|
effort = AgentModels.effort_for(effective)
|
|
650
775
|
if effort
|
|
651
|
-
|
|
776
|
+
lines = []
|
|
777
|
+
model = AgentModels.codex_model_for(effective)
|
|
778
|
+
lines << %(model = "#{toml_inline_escape(model)}") if model && !model.to_s.empty?
|
|
779
|
+
lines << %(model_reasoning_effort = "#{effort}")
|
|
780
|
+
lines.join("\n")
|
|
652
781
|
else
|
|
653
782
|
%(model = "#{toml_inline_escape(effective.to_s)}")
|
|
654
783
|
end
|
|
@@ -716,13 +845,21 @@ class InstallerCore
|
|
|
716
845
|
end
|
|
717
846
|
|
|
718
847
|
def install_hermes(config, force)
|
|
848
|
+
FileUtils.mkdir_p(record_dir_for(config))
|
|
849
|
+
|
|
719
850
|
installed = []
|
|
720
851
|
skills_source = File.join(package_root, "skills")
|
|
721
852
|
skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
|
|
722
853
|
installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
|
|
723
854
|
installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
|
|
724
855
|
|
|
725
|
-
|
|
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))
|
|
726
863
|
{ agent: config[:name], success: true, files: installed.size }
|
|
727
864
|
end
|
|
728
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
|
```
|