@zalom/plastic 1.1.5 → 1.3.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/scripts/doctor.rb CHANGED
@@ -34,7 +34,8 @@ class Doctor
34
34
 
35
35
  DEFAULT_AGENTS = {
36
36
  "claude" => { name: "Claude Code", dir: File.join(Dir.home, ".claude") },
37
- "codex" => { name: "Codex CLI", dir: File.join(Dir.home, ".agents") },
37
+ "codex" => { name: "Codex CLI", dir: File.join(Dir.home, ".agents"),
38
+ home_dir: File.join(Dir.home, ".codex") },
38
39
  "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
39
40
  }.freeze
40
41
 
@@ -980,6 +981,8 @@ class Doctor
980
981
  case agent_key
981
982
  when "claude"
982
983
  checks += check_claude_registration(agent_dir)
984
+ when "codex"
985
+ checks += check_codex_registration(agent_key, agent_dir)
983
986
  else
984
987
  checks += check_generic_agent_registration(agent_key, agent_dir)
985
988
  end
@@ -1196,9 +1199,11 @@ class Doctor
1196
1199
  end
1197
1200
  end
1198
1201
 
1199
- def check_generic_agent_registration(agent_key, agent_dir)
1202
+ # Shared skills-presence plus stray-skills check, used by both the generic
1203
+ # (hermes) agent-registration path and codex's TOML-based one. Neither the flat
1204
+ # `.md` agents check nor anything agent-format-specific lives here.
1205
+ def check_flat_skills_and_stray(agent_key, agent_dir)
1200
1206
  checks = []
1201
- config = agents[agent_key]
1202
1207
 
1203
1208
  # For codex/hermes: just check skills exist (no settings.json hooks)
1204
1209
  checks << flat_skills_check(agent_dir, "--#{agent_key}")
@@ -1208,11 +1213,162 @@ class Doctor
1208
1213
  stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic-manifest.json"))
1209
1214
  checks << stray_check if stray_check
1210
1215
 
1211
- checks << flat_agents_check(agent_dir, "--#{agent_key}")
1216
+ checks
1217
+ end
1218
+
1219
+ def check_generic_agent_registration(agent_key, agent_dir)
1220
+ checks = check_flat_skills_and_stray(agent_key, agent_dir)
1221
+ checks << flat_agents_check(agent_dir, "--#{agent_key}") # hermes: unchanged (.md copy)
1222
+ checks
1223
+ end
1224
+
1225
+ # Codex marker literals. Keep in sync with InstallerCore::CODEX_SECTION_BEGIN_PREFIX /
1226
+ # CODEX_SECTION_END (doctor does not require installer_core, so the literals are duplicated).
1227
+ CODEX_SECTION_BEGIN_PREFIX = "<!-- BEGIN PLASTIC INTEGRATION"
1228
+ CODEX_SECTION_END = "<!-- END PLASTIC INTEGRATION -->"
1229
+
1230
+ def check_codex_registration(agent_key, agent_dir)
1231
+ config = agents[agent_key]
1232
+ checks = check_flat_skills_and_stray(agent_key, agent_dir)
1233
+ checks << codex_agents_toml_check(config)
1234
+
1235
+ agents_md = File.join(config[:home_dir], "AGENTS.md")
1236
+
1237
+ if !File.exist?(agents_md)
1238
+ checks << check(
1239
+ category: "agent_registration", name: "codex_agents_md", status: "fail",
1240
+ message: "Codex AGENTS.md not found at #{tilde(agents_md)}",
1241
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1242
+ )
1243
+ else
1244
+ content = File.read(agents_md)
1245
+ b = content.index(CODEX_SECTION_BEGIN_PREFIX)
1246
+ e = content.index(CODEX_SECTION_END)
1247
+ well_formed = b && e && e > b && content[b...e].include?("-->")
1248
+ if well_formed
1249
+ checks << check(
1250
+ category: "agent_registration", name: "codex_agents_md", status: "pass",
1251
+ message: "Codex AGENTS.md carries the Plastic section"
1252
+ )
1253
+ else
1254
+ checks << check(
1255
+ category: "agent_registration", name: "codex_agents_md", status: "fail",
1256
+ message: "Codex AGENTS.md is missing or has a malformed Plastic section",
1257
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1258
+ )
1259
+ end
1260
+ end
1261
+
1262
+ checks << codex_hooks_registered_check(config)
1263
+ codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
1212
1264
 
1213
1265
  checks
1214
1266
  end
1215
1267
 
1268
+ # Codex-specific agent presence + structural sanity: ~/.codex/agents/plastic-*.toml
1269
+ # exist and each carries the mandatory fields. No TOML parser (doctor depends on none):
1270
+ # "structural" means the mandatory keys appear as lines and the multi-line
1271
+ # developer_instructions string is balanced (an opening triple-quote has a later one).
1272
+ def codex_agents_toml_check(config)
1273
+ agents_root = File.join(config[:home_dir], "agents")
1274
+ found = Dir.glob(File.join(agents_root, "plastic-*.toml"))
1275
+
1276
+ if found.empty?
1277
+ return check(
1278
+ category: "agent_registration", name: "codex_agents_toml", status: "fail",
1279
+ message: "No plastic-* agent TOML files found in #{tilde(agents_root)}",
1280
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1281
+ )
1282
+ end
1283
+
1284
+ malformed = found.reject { |f| codex_agent_toml_well_formed?(File.read(f)) }
1285
+ if malformed.empty?
1286
+ check(
1287
+ category: "agent_registration", name: "codex_agents_toml", status: "pass",
1288
+ message: "#{found.size} plastic-* agent TOML(s) installed in #{tilde(agents_root)}"
1289
+ )
1290
+ else
1291
+ check(
1292
+ category: "agent_registration", name: "codex_agents_toml", status: "fail",
1293
+ message: "#{malformed.size} Codex agent TOML(s) missing mandatory fields",
1294
+ details: malformed.map { |f| tilde(f) },
1295
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1296
+ )
1297
+ end
1298
+ end
1299
+
1300
+ def codex_agent_toml_well_formed?(content)
1301
+ marker = 'developer_instructions = """'
1302
+ di = content.index(marker)
1303
+ has_name = content.match?(/^name\s*=\s*"/)
1304
+ has_desc = content.match?(/^description\s*=\s*"/)
1305
+ balanced = di && content.index('"""', di + marker.length)
1306
+ has_name && has_desc && !di.nil? && !balanced.nil?
1307
+ end
1308
+
1309
+ # codex_hooks_registered (intent 102, the owner-facing first-run validation
1310
+ # path, Decision 14): ~/.codex/hooks.json must carry EXACTLY the commands
1311
+ # HookRegistry.codex_hooks_json defines, mirroring the Claude
1312
+ # hooks_match_registry diff. Never writes.
1313
+ def codex_hooks_registered_check(config)
1314
+ hooks_json = File.join(config[:home_dir], "hooks.json")
1315
+ dispatcher = File.join(plastic_home, "scripts", "codex-hook")
1316
+ data = read_json_safe(hooks_json)
1317
+
1318
+ if data.nil?
1319
+ return check(
1320
+ category: "agent_registration", name: "codex_hooks_registered", status: "fail",
1321
+ message: "Codex hooks.json missing or unreadable at #{tilde(hooks_json)}",
1322
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1323
+ )
1324
+ end
1325
+
1326
+ expected = HookRegistry.codex_hooks_json(dispatcher_path: dispatcher)
1327
+ live = data["hooks"] || {}
1328
+ missing = []
1329
+ expected.each do |event, groups|
1330
+ want = Array(groups).flat_map { |g| g["hooks"].map { |h| h["command"] } }
1331
+ got = Array(live[event]).flat_map { |g| Array(g["hooks"]).map { |h| h["command"] } }
1332
+ missing.concat(want - got)
1333
+ end
1334
+
1335
+ if missing.empty?
1336
+ check(
1337
+ category: "agent_registration", name: "codex_hooks_registered", status: "pass",
1338
+ message: "Codex hooks registered in hooks.json"
1339
+ )
1340
+ else
1341
+ check(
1342
+ category: "agent_registration", name: "codex_hooks_registered", status: "fail",
1343
+ message: "Codex hooks.json missing #{missing.size} command(s)",
1344
+ details: missing,
1345
+ fixable: true, fix_hint: "Re-run the Plastic installer with --codex"
1346
+ )
1347
+ end
1348
+ end
1349
+
1350
+ # config.toml advisory (intent 102, Decision 2, R2): READ ONLY. Warns on the two
1351
+ # documented footguns (hooks disabled, sandbox read-only); never writes, and
1352
+ # returns nil (no check emitted) when config.toml is absent or carries neither.
1353
+ def codex_config_toml_advisory_check(config)
1354
+ config_toml = File.join(config[:home_dir], "config.toml")
1355
+ return nil unless File.exist?(config_toml)
1356
+
1357
+ toml = File.read(config_toml) rescue ""
1358
+ warns = []
1359
+ # guide Part 3: `codex_hooks` is a deprecated alias for `[features] hooks`; catch both.
1360
+ warns << "hooks are disabled ([features] hooks = false); Plastic gates will not fire" if toml.match?(/^\s*(?:codex_)?hooks\s*=\s*false/)
1361
+ warns << "sandbox_mode = \"read-only\"; apply_patch writes (and gates) cannot run" if toml.match?(/^\s*sandbox_mode\s*=\s*["']read-only["']/)
1362
+ return nil if warns.empty?
1363
+
1364
+ check(
1365
+ category: "agent_registration", name: "codex_config_advisory", status: "warn",
1366
+ message: warns.join("; "),
1367
+ fixable: false,
1368
+ fix_hint: "Set [features] hooks = true and sandbox_mode = \"workspace-write\" in ~/.codex/config.toml"
1369
+ )
1370
+ end
1371
+
1216
1372
  # --- Check category 4: Core files ---
1217
1373
 
1218
1374
  def check_core_files(agent_key)
@@ -57,6 +57,7 @@ class Install < InstallerCore
57
57
 
58
58
  distribute(mode)
59
59
  bootstrap if fresh
60
+ apply_config_flags(argv)
60
61
 
61
62
  results = selected.map { |key| install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall) }
62
63
 
@@ -158,6 +159,13 @@ class Install < InstallerCore
158
159
  --statusline VALUE keep or plastic. If an existing statusline is found, this
159
160
  skips the interactive prompt. Interactive sessions ask by
160
161
  default; non-interactive sessions default to keep.
162
+ --no-advisor Skip installing both advisor agents and the agent-advisor
163
+ skill (advisor.enabled: false)
164
+ --advisor VALUE Which advisor agent is the default: an agent name, or the
165
+ shorthand "real" (plastic-advisor) or "faux"
166
+ (plastic-faux-advisor). Writes advisor.claude.default. Left
167
+ unset, the agent-advisor skill falls back to
168
+ plastic-faux-advisor at consult time.
161
169
  -h, --help Show this help
162
170
 
163
171
  Notes:
@@ -21,23 +21,68 @@ module AgentModels
21
21
  "plastic-intent-discovery" => "sonnet"
22
22
  }.freeze
23
23
 
24
+ # The two advisor agents (intent 185 final design): plastic-advisor (the real
25
+ # advisor, ships `model: fable`) and plastic-faux-advisor (the imitation
26
+ # advisor, an ordinary model carrying the same reasoning discipline inline,
27
+ # ships `model: opus`). Both are shipped DEFAULTS in frontmatter, never a
28
+ # hard-wired identity: agents.models.claude.<name> (or the legacy flat form)
29
+ # overrides either one through the same install-time frontmatter rewrite
30
+ # every agent override uses. Neither is a lifecycle-stage role: never
31
+ # dispatched by the auto pipeline, not part of TIER_DEFAULTS. Claude-only for
32
+ # this release (generate_codex_agents skips both by name; the Codex advisor
33
+ # case is intent 186, not a permanent exclusion).
34
+ CONSULTATION_AGENTS = %w[plastic-advisor plastic-faux-advisor].freeze
35
+
36
+ # Codex reasoning-effort per tier alias (intent 102a). model_reasoning_effort is a
37
+ # depth-of-thinking dial independent of model selection (181 line 317-318), so mapping
38
+ # the tier here never encodes a rotting Codex model id (116 D1). opus is the deepest
39
+ # reasoning tier -> the deepest generally-safe effort (high, not the model-dependent
40
+ # xhigh); sonnet the mid execution tier -> medium; haiku the lightest -> low. minimal is
41
+ # unused.
42
+ EFFORT_BY_ALIAS = {
43
+ "opus" => "high",
44
+ "sonnet" => "medium",
45
+ "haiku" => "low"
46
+ }.freeze
47
+
24
48
  module_function
25
49
 
26
- # Pull the `agents.models` sub-hash out of a loaded config hash, tolerating a
27
- # missing or malformed shape. Returns a plain { basename => model } hash.
28
- def models_section(config)
50
+ # Pull { basename => model } out of a loaded config hash's `agents.models`
51
+ # section, scoped to `harness` ("claude" or "codex"), tolerating a missing or
52
+ # malformed shape. `agents.models` can mix two shapes: legacy FLAT scalar
53
+ # entries (agents.models.plastic-executor: sonnet), honored as the claude
54
+ # harness only, and harness-scoped sub-hashes (agents.models.claude.*,
55
+ # agents.models.codex.*). Nested wins over flat for the same agent on the
56
+ # claude harness; a non-claude harness reads ONLY its own nested sub-hash,
57
+ # never the flat entries, so a literal model id written under the flat form
58
+ # (or agents.models.claude.*) can never leak into another harness's config.
59
+ def models_section(config, harness: "claude")
29
60
  return {} unless config.is_a?(Hash)
30
61
  agents = config["agents"]
31
62
  return {} unless agents.is_a?(Hash)
32
63
  section = agents["models"]
33
- section.is_a?(Hash) ? section : {}
64
+ return {} unless section.is_a?(Hash)
65
+
66
+ nested = section[harness]
67
+ nested = nested.is_a?(Hash) ? nested : {}
68
+ return nested unless harness == "claude"
69
+
70
+ flat = section.reject { |_key, value| value.is_a?(Hash) }
71
+ flat.merge(nested)
34
72
  end
35
73
 
36
74
  # Override map for the installer: global overrides overlaid by project
37
- # overrides (project wins). Defaults are intentionally excluded. Unknown agent
38
- # keys are carried through as-is; install_agents simply never matches them to a
39
- # copied file, so they are ignored without raising.
40
- def override_map(project_config: {}, global_config: {})
41
- models_section(global_config).merge(models_section(project_config))
75
+ # overrides (project wins), scoped to `harness`. Defaults are intentionally
76
+ # excluded. Unknown agent keys are carried through as-is; install_agents
77
+ # simply never matches them to a copied file, so they are ignored without
78
+ # raising.
79
+ def override_map(project_config: {}, global_config: {}, harness: "claude")
80
+ models_section(global_config, harness: harness).merge(models_section(project_config, harness: harness))
81
+ end
82
+
83
+ # The model_reasoning_effort for a Plastic tier alias, or nil for any value that is not
84
+ # one of the three shipped aliases (the caller treats nil as a literal Codex model id).
85
+ def effort_for(value)
86
+ EFFORT_BY_ALIAS[value.to_s]
42
87
  end
43
88
  end
@@ -0,0 +1,76 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Parses a Codex `apply_patch` command envelope into an ordered list of file
5
+ # operations. Codex delivers file mutations as a V4A diff envelope in
6
+ # tool_input.command, not a clean file_path+content pair, and ONE apply_patch call
7
+ # can bundle several files. This is the single translation the Codex gate shims
8
+ # need; the Ruby gate/savepoint cores stay payload-agnostic behind it.
9
+ #
10
+ # Guide-settled shapes consumed here (intent 102, Decision 0, owner ruling):
11
+ # [guide Part 3] the `~/.codex/hooks.json` registration schema (consumed by
12
+ # HookRegistry.codex_hooks_json and merge_codex_hooks, not this file). [guide
13
+ # Part 4] the hook stdin schema, including `tool_input.command` as the carrier of
14
+ # the apply_patch envelope text (consumed by scripts/codex-hook).
15
+ #
16
+ # Residual gap (in neither the guide nor 181, per Decision 14): the apply_patch
17
+ # V4A envelope INNER grammar parsed below (`*** Begin/End Patch`, `*** Add/Update/
18
+ # Delete File:`, `*** Move to:`, `+`/`-`/context lines) is not primary-sourced.
19
+ # There is no live Codex to verify it against (the owner has none installed), so
20
+ # this parser is built to the best-known public V4A shape and FAILS OPEN on
21
+ # anything else. Returns [] and warns on any missing or unparseable envelope:
22
+ # gates fail OPEN (orchestrator-locks fail-open rule). The PostToolUse gate-check
23
+ # artifact backstop re-validates the intent file after the write, so a fail-open
24
+ # create-gate is still netted. A real Codex run after delivery is the only future
25
+ # check on this residual.
26
+ module ApplyPatchEnvelope
27
+ module_function
28
+
29
+ Op = Struct.new(:op, :path, :added_content, keyword_init: true)
30
+
31
+ BEGIN_MARK = "*** Begin Patch"
32
+ END_MARK = "*** End Patch"
33
+ ADD_RE = /\A\*\*\* Add File: (.+)\z/
34
+ UPDATE_RE = /\A\*\*\* Update File: (.+)\z/
35
+ DELETE_RE = /\A\*\*\* Delete File: (.+)\z/
36
+ MOVE_RE = /\A\*\*\* Move to: (.+)\z/
37
+
38
+ # command may be a String or (per Step 1) an Array of argv; normalize to the
39
+ # patch text by scanning for the Begin/End markers regardless of wrapping.
40
+ def parse(command)
41
+ text = command.is_a?(Array) ? command.join("\n") : command.to_s
42
+ b = text.index(BEGIN_MARK)
43
+ e = text.index(END_MARK)
44
+ return warn_empty("no Begin/End Patch markers") if b.nil? || e.nil? || e < b
45
+
46
+ body = text[(b + BEGIN_MARK.length)...e]
47
+ ops = []
48
+ current = nil
49
+ body.each_line do |raw|
50
+ line = raw.chomp
51
+ if (m = ADD_RE.match(line))
52
+ current = Op.new(op: :add, path: m[1].strip, added_content: +"")
53
+ ops << current
54
+ elsif (m = UPDATE_RE.match(line))
55
+ current = Op.new(op: :update, path: m[1].strip, added_content: +"")
56
+ ops << current
57
+ elsif (m = DELETE_RE.match(line))
58
+ current = Op.new(op: :delete, path: m[1].strip, added_content: nil)
59
+ ops << current
60
+ elsif (m = MOVE_RE.match(line)) && current
61
+ current.path = m[1].strip # rename target becomes the effective path
62
+ elsif current && current.added_content && line.start_with?("+")
63
+ current.added_content << line[1..].to_s << "\n"
64
+ end
65
+ # context (' '), removed ('-'), and '@@' lines are ignored for added_content
66
+ end
67
+ ops
68
+ rescue StandardError => e
69
+ warn_empty("parse error: #{e.message}")
70
+ end
71
+
72
+ def warn_empty(reason)
73
+ $stderr.puts "plastic apply_patch parse: #{reason}; gate fails open"
74
+ []
75
+ end
76
+ end
@@ -75,6 +75,38 @@ module HookRegistry
75
75
  }
76
76
  end
77
77
 
78
+ # Codex registration (~/.codex/hooks.json, intent 102). Derived from `events`:
79
+ # the file-mutation PreToolUse gate/savepoint hooks collapse from Claude's
80
+ # multi-tool matchers onto Codex's single apply_patch tool (181 F4: apply_patch
81
+ # is Codex's sole file-mutation tool; tool_name always reports apply_patch), plus
82
+ # the PostToolUse gate-check. Command invokes the codex-hook dispatcher with the
83
+ # gate name. Guide-settled shape [guide Part 3]: top-level {"hooks":{<Event>:
84
+ # [{"matcher","hooks":[{"type":"command","command","statusMessage"}]}]}},
85
+ # identical to Claude's shape, string command. Single source of truth (108 D7):
86
+ # any drift from `events` is a bug, pinned by test.
87
+ CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre create-gate].freeze
88
+ CODEX_POST_HOOKS = %w[gate-check].freeze
89
+
90
+ def codex_hooks_json(dispatcher_path:)
91
+ # name => statusMessage, straight from the single `events` source (A8): the
92
+ # guide Part 3 hooks.json format carries a per-hook statusMessage, so emit it.
93
+ status_by_name = events.values.flatten.flat_map { |g| g["hooks"] }
94
+ .each_with_object({}) { |h, m| m[h["name"]] = h["status"] }
95
+ cmd = ->(name) {
96
+ { "type" => "command",
97
+ "command" => "\"#{dispatcher_path}\" #{name}",
98
+ "statusMessage" => status_by_name[name].to_s }
99
+ }
100
+ # Preserve the order these hook names appear across the PreToolUse groups in `events`.
101
+ pre_order = events["PreToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
102
+ pre = (pre_order & CODEX_PRE_HOOKS).map { |n| cmd.call(n) }
103
+ post = CODEX_POST_HOOKS.map { |n| cmd.call(n) }
104
+ {
105
+ "PreToolUse" => [{ "matcher" => "apply_patch", "hooks" => pre }],
106
+ "PostToolUse" => [{ "matcher" => "apply_patch", "hooks" => post }],
107
+ }
108
+ end
109
+
78
110
  # The settings.json shape merge_claude_hooks expects: single-group events map
79
111
  # to a Hash, multi-group events to an Array (the merge loop handles both).
80
112
  def claude_settings_hooks(hook_dir:)