@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.5.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
@@ -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-manifest.json"))
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: 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"
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: 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"
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: claude -> <dir>/plastic/manifest.json
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 = if agent_key == "claude"
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
@@ -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
@@ -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
- 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
 
@@ -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, one model field, developer_instructions) so regenerate is
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 single model-selection line. A known tier alias (opus/sonnet/haiku) emits
645
- # model_reasoning_effort only; any other non-empty value is a literal Codex model id
646
- # emitted verbatim as `model`. Empty -> no line (the agent inherits the session default).
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
- %(model_reasoning_effort = "#{effort}")
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
- 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))
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
- 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
  ```