@zalom/plastic 1.5.0 → 1.6.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.6.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 ---
@@ -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)
@@ -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
@@ -613,8 +613,8 @@ class InstallerCore
613
613
  end
614
614
 
615
615
  # 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).
616
+ # order (name, description, the model field(s) from codex_model_fields, developer_instructions)
617
+ # so regenerate is byte-identical (idempotency).
618
618
  def render_codex_agent_toml(source_path, override)
619
619
  front, body = split_frontmatter(File.read(source_path))
620
620
  name = (front["name"] || File.basename(source_path, ".md")).to_s
@@ -641,14 +641,21 @@ class InstallerCore
641
641
  end
642
642
  end
643
643
 
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).
644
+ # The model-selection line(s). A known tier alias (opus/sonnet/haiku) emits BOTH a `model` line
645
+ # (from AgentModels.codex_model_for, the intent-186 per-role Codex identity) and a
646
+ # model_reasoning_effort line, model first for deterministic byte-identical regenerate. Any other
647
+ # non-empty value is a literal Codex model id emitted verbatim as `model` only. Empty -> no line
648
+ # (the agent inherits the session default). If an alias somehow lacks a mapped model, the effort
649
+ # line still emits alone (backward-safe).
647
650
  def codex_model_fields(effective)
648
651
  return "" if effective.nil? || effective.to_s.empty?
649
652
  effort = AgentModels.effort_for(effective)
650
653
  if effort
651
- %(model_reasoning_effort = "#{effort}")
654
+ lines = []
655
+ model = AgentModels.codex_model_for(effective)
656
+ lines << %(model = "#{toml_inline_escape(model)}") if model && !model.to_s.empty?
657
+ lines << %(model_reasoning_effort = "#{effort}")
658
+ lines.join("\n")
652
659
  else
653
660
  %(model = "#{toml_inline_escape(effective.to_s)}")
654
661
  end