@zalom/plastic 1.1.4 → 1.2.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/PLASTIC.md CHANGED
@@ -10,6 +10,7 @@ See `PLASTIC-reference.md` for reference material: read it on demand, it is not
10
10
 
11
11
  A directory in the store containing `{ID}--{slug}.md` and optional supporting files.
12
12
  It represents a desire: something a human or agent wants to accomplish, explore, or understand.
13
+ The unit of work in Plastic is always an intent, never a ticket.
13
14
 
14
15
  ```
15
16
  store/
package/README.md CHANGED
@@ -44,7 +44,7 @@ Plastic makes that thinking legible, resumable, and useful later.
44
44
 
45
45
  **Plastic is built for an AI-native developer, technical founder, or
46
46
  independent builder who works across multiple sessions, has ideas before
47
- they have tickets, and feels the cost of losing reasoning between agents,
47
+ they have intents, and feels the cost of losing reasoning between agents,
48
48
  contexts, and days.**
49
49
 
50
50
  ## What it solves?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ #
5
+ # Usage: codex-hook <gate> (gate: code-gate | lock-gate | savepoint-pre | gate-check | create-gate)
6
+ #
7
+ # The Codex input adapter (intent 102). Reads a Codex hook stdin payload
8
+ # (session_id at top level, the apply_patch command in tool_input.command,
9
+ # [guide Part 4]), parses the diff envelope via ApplyPatchEnvelope, and drives
10
+ # Plastic's payload-agnostic Ruby gate/savepoint cores once per file operation.
11
+ # The cores' output contracts are already Codex-compatible, so their stdout/
12
+ # stderr/exit are relayed unchanged (see ACTION_1's ARGV-contract table).
13
+ require "json"
14
+ require "rbconfig"
15
+ require_relative "lib/apply_patch_envelope"
16
+ require_relative "lib/intent_validator"
17
+
18
+ gate = ARGV[0].to_s
19
+ raw = ($stdin.read rescue nil)
20
+ exit 0 if raw.nil? || raw.strip.empty?
21
+ payload = (JSON.parse(raw) rescue nil)
22
+ exit 0 unless payload.is_a?(Hash)
23
+
24
+ session = payload["session_id"].to_s
25
+ command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
26
+ ops = ApplyPatchEnvelope.parse(command)
27
+ exit 0 if ops.empty? # fail-open: nothing parseable to gate
28
+
29
+ CORES = __dir__ # ~/.plastic/scripts
30
+ def run_core(name, *argv)
31
+ out = IO.popen([RbConfig.ruby, File.join(CORES, name), *argv], "r", err: [:child, :out], &:read)
32
+ [out, $?.exitstatus]
33
+ end
34
+
35
+ def intent_file?(path)
36
+ abs = File.expand_path(path)
37
+ dir = File.dirname(abs)
38
+ dir.match?(%r{/store/[^/]+--[^/]+\z}) && File.basename(abs) == "#{File.basename(dir)}.md"
39
+ end
40
+
41
+ case gate
42
+ when "create-gate"
43
+ # Pre-write veto: born-complete validation for Add ops on intent files only.
44
+ # Update/Delete/Move defer to the PostToolUse gate-check backstop (spec Decision 5).
45
+ ops.each do |o|
46
+ next unless o.op == :add && intent_file?(o.path)
47
+ result = IntentValidator.validate_content(o.added_content.to_s)
48
+ next if result[:ok]
49
+ $stderr.puts "PLASTIC CREATE GATE - #{File.basename(o.path)} is not a valid intent:"
50
+ result[:errors].each { |e| $stderr.puts " #{e}" }
51
+ $stderr.puts "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
52
+ exit 2
53
+ end
54
+ exit 0
55
+
56
+ when "lock-gate"
57
+ # Deny is signalled by JSON on stdout at exit 0. Relay the first deny.
58
+ ops.each do |o|
59
+ out, _ = run_core("hook-lock-gate", o.path, session)
60
+ if out.include?('"permissionDecision":"deny"') || out.include?('"permissionDecision": "deny"')
61
+ print out
62
+ exit 0
63
+ end
64
+ end
65
+ exit 0
66
+
67
+ when "code-gate"
68
+ ops.each do |o|
69
+ out, code = run_core("hook-code-gate", o.path, session, o.added_content.to_s)
70
+ if code == 2
71
+ $stderr.print out
72
+ exit 2
73
+ end
74
+ end
75
+ exit 0
76
+
77
+ when "gate-check"
78
+ last_allow = nil
79
+ ops.each do |o|
80
+ out, code = run_core("hook-gate-check", o.path, session)
81
+ if code == 2
82
+ print out # {"decision":"block",...}
83
+ exit 2
84
+ end
85
+ last_allow = out unless out.to_s.strip.empty?
86
+ end
87
+ print last_allow if last_allow
88
+ exit 0
89
+
90
+ when "savepoint-pre"
91
+ ops.each { |o| run_core("hook-savepoint-pre", o.path) }
92
+ exit 0
93
+
94
+ else
95
+ exit 0
96
+ end
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)
@@ -21,6 +21,18 @@ module AgentModels
21
21
  "plastic-intent-discovery" => "sonnet"
22
22
  }.freeze
23
23
 
24
+ # Codex reasoning-effort per tier alias (intent 102a). model_reasoning_effort is a
25
+ # depth-of-thinking dial independent of model selection (181 line 317-318), so mapping
26
+ # the tier here never encodes a rotting Codex model id (116 D1). opus is the deepest
27
+ # reasoning tier -> the deepest generally-safe effort (high, not the model-dependent
28
+ # xhigh); sonnet the mid execution tier -> medium; haiku the lightest -> low. minimal is
29
+ # unused.
30
+ EFFORT_BY_ALIAS = {
31
+ "opus" => "high",
32
+ "sonnet" => "medium",
33
+ "haiku" => "low"
34
+ }.freeze
35
+
24
36
  module_function
25
37
 
26
38
  # Pull the `agents.models` sub-hash out of a loaded config hash, tolerating a
@@ -40,4 +52,10 @@ module AgentModels
40
52
  def override_map(project_config: {}, global_config: {})
41
53
  models_section(global_config).merge(models_section(project_config))
42
54
  end
55
+
56
+ # The model_reasoning_effort for a Plastic tier alias, or nil for any value that is not
57
+ # one of the three shipped aliases (the caller treats nil as a literal Codex model id).
58
+ def effort_for(value)
59
+ EFFORT_BY_ALIAS[value.to_s]
60
+ end
43
61
  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:)
@@ -18,10 +18,39 @@ class InstallerCore
18
18
 
19
19
  DEFAULT_AGENTS = [
20
20
  { key: "claude", name: "Claude Code", dir: File.join(Dir.home, ".claude"), flag: "--claude" },
21
- { key: "codex", name: "Codex CLI", dir: File.join(Dir.home, ".agents"), flag: "--codex" },
21
+ { key: "codex", name: "Codex CLI", dir: File.join(Dir.home, ".agents"),
22
+ home_dir: File.join(Dir.home, ".codex"), flag: "--codex" },
22
23
  { key: "hermes", name: "Hermes", dir: File.join(Dir.home, ".hermes"), flag: "--hermes" },
23
24
  ].freeze
24
25
 
26
+ # Codex AGENTS.md marked-section markers (single source of truth; doctor.rb
27
+ # matches these literals structurally, so keep the two in sync by hand).
28
+ CODEX_SECTION_BEGIN_PREFIX = "<!-- BEGIN PLASTIC INTEGRATION"
29
+ CODEX_SECTION_END = "<!-- END PLASTIC INTEGRATION -->"
30
+
31
+ # Regex matching exactly one managed section (BEGIN line .. END line), non-greedy.
32
+ CODEX_SECTION_RE = /^<!-- BEGIN PLASTIC INTEGRATION.*?-->\n.*?\n<!-- END PLASTIC INTEGRATION -->\n?/m
33
+
34
+ # Curated essentials plus a pointer to ~/.plastic/PLASTIC.md, injected into
35
+ # ~/.codex/AGENTS.md. Not a slice of PLASTIC.md (which is already over the 32 KiB
36
+ # AGENTS.md merge cap on its own), so it never drifts and carries no maintenance fork.
37
+ CODEX_AGENTS_MD_BODY = <<~MD.freeze
38
+ Plastic is installed for this agent. Plastic is intent-driven state management: all
39
+ work flows through an intent, moved through What, Why, How, then Exec. Do not jump
40
+ straight to code.
41
+
42
+ Standing rules:
43
+ - The full conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly.
44
+ It is generated and overwritten on Plastic updates, so never edit it.
45
+ - Operational procedures are installed as skills under ~/.agents/skills/ (each
46
+ plastic-<name>/SKILL.md). Use them for the lifecycle work they describe.
47
+ - Intents, specs, plans, checklists, and outcomes live under ~/.plastic/, never in
48
+ the project tree.
49
+
50
+ This section is managed by the Plastic installer. It is replaced on update and removed
51
+ on uninstall. Do not edit anything between the BEGIN and END markers.
52
+ MD
53
+
25
54
  attr_reader :package_root, :plastic_home, :version, :agents
26
55
 
27
56
  def initialize(package_root:, plastic_home: DEFAULT_PLASTIC_HOME, version: nil, agents: DEFAULT_AGENTS)
@@ -278,6 +307,8 @@ class InstallerCore
278
307
  "scripts/new-intent" => "scripts/new-intent",
279
308
  "scripts/end-intent" => "scripts/end-intent",
280
309
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
310
+ "scripts/lib/apply_patch_envelope.rb" => "scripts/lib/apply_patch_envelope.rb",
311
+ "scripts/codex-hook" => "scripts/codex-hook",
281
312
  "templates/intent.md" => "templates/intent.md",
282
313
  "templates/spec.md" => "templates/spec.md",
283
314
  "templates/plan.md" => "templates/plan.md",
@@ -465,12 +496,146 @@ class InstallerCore
465
496
  installed = []
466
497
  skills_source = File.join(package_root, "skills")
467
498
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
468
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
499
+ installed += generate_codex_agents(File.join(config[:home_dir], "agents"), models: agent_model_overrides)
500
+
501
+ # Instruction injection (L1): Plastic standing conventions into ~/.codex/AGENTS.md.
502
+ # Partial-ownership file, so it is NOT manifest-tracked (stripped surgically on uninstall).
503
+ FileUtils.mkdir_p(config[:home_dir])
504
+ inject_codex_agents_md(File.join(config[:home_dir], "AGENTS.md"))
505
+
506
+ # L3 hooks (intent 102): register into ~/.codex/hooks.json (user scope, defeats the
507
+ # worktree bug). Partial-ownership file, so it is merged and NOT manifest-tracked
508
+ # (stripped surgically on uninstall), same treatment as AGENTS.md.
509
+ merge_codex_hooks(File.join(config[:home_dir], "hooks.json"))
469
510
 
470
511
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
471
512
  { agent: config[:name], success: true, files: installed.size }
472
513
  end
473
514
 
515
+ # --- Codex agent TOML generation (intent 102a) ---
516
+ #
517
+ # Codex reads standalone TOML agent files at ~/.codex/agents/*.toml (config[:home_dir]),
518
+ # never the ~/.agents/agents/*.md copy the shared install_agents writes (that root is
519
+ # the cross-tool skills standard, not an agents root). The codex leg therefore generates
520
+ # a whole-file, Plastic-owned .toml per repo agents/*.md instead of copying markdown.
521
+ # The returned paths append to `installed`, so they are manifest-tracked and pruned on
522
+ # uninstall by the manifest whole-file-delete path, exactly like ~/.claude/agents/*.md.
523
+ def generate_codex_agents(agents_root, models: {})
524
+ sources = Dir.glob(File.join(package_root, "agents", "*.md"))
525
+ return [] if sources.empty?
526
+
527
+ FileUtils.mkdir_p(agents_root)
528
+ sources.map do |src|
529
+ basename = File.basename(src, ".md")
530
+ dest = File.join(agents_root, "#{basename}.toml")
531
+ write_text_atomic(dest, render_codex_agent_toml(src, models[basename]))
532
+ dest
533
+ end
534
+ end
535
+
536
+ # Render one repo agents/*.md into a deterministic Codex agent TOML document. Fixed field
537
+ # order (name, description, one model field, developer_instructions) so regenerate is
538
+ # byte-identical (idempotency).
539
+ def render_codex_agent_toml(source_path, override)
540
+ front, body = split_frontmatter(File.read(source_path))
541
+ name = (front["name"] || File.basename(source_path, ".md")).to_s
542
+ description = (front["description"] || "").to_s
543
+ effective = (override && !override.to_s.empty? ? override : front["model"]).to_s
544
+
545
+ parts = []
546
+ parts << %(name = "#{toml_inline_escape(name)}")
547
+ parts << %(description = "#{toml_inline_escape(description)}")
548
+ parts << codex_model_fields(effective)
549
+ parts << "developer_instructions = \"\"\"\n#{toml_ml_escape(body.strip)}\n\"\"\""
550
+ parts.reject(&:empty?).join("\n") + "\n"
551
+ end
552
+
553
+ # Split a Plastic agent .md into [frontmatter_hash, body]. Tolerant: a file with no
554
+ # frontmatter yields [{}, whole content].
555
+ def split_frontmatter(content)
556
+ if content =~ /\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m
557
+ front = YAML.safe_load($1) rescue {}
558
+ front = {} unless front.is_a?(Hash)
559
+ [front, $2]
560
+ else
561
+ [{}, content]
562
+ end
563
+ end
564
+
565
+ # The single model-selection line. A known tier alias (opus/sonnet/haiku) emits
566
+ # model_reasoning_effort only; any other non-empty value is a literal Codex model id
567
+ # emitted verbatim as `model`. Empty -> no line (the agent inherits the session default).
568
+ def codex_model_fields(effective)
569
+ return "" if effective.nil? || effective.to_s.empty?
570
+ effort = AgentModels.effort_for(effective)
571
+ if effort
572
+ %(model_reasoning_effort = "#{effort}")
573
+ else
574
+ %(model = "#{toml_inline_escape(effective.to_s)}")
575
+ end
576
+ end
577
+
578
+ # Escape arbitrary text for a TOML multi-line basic string ("""..."""). Order matters:
579
+ # normalize line endings, escape backslash FIRST (so introduced escapes are not
580
+ # re-escaped), then EVERY double-quote (which alone prevents any triple-quote delimiter
581
+ # collision), then C0 control chars other than tab/newline.
582
+ def toml_ml_escape(str)
583
+ s = str.to_s.gsub(/\r\n?/, "\n")
584
+ s = s.gsub("\\") { "\\\\" }
585
+ s = s.gsub('"') { '\\"' }
586
+ s.gsub(/[\x00-\x08\x0b\x0c\x0e-\x1f]/) { |c| format('\u%04X', c.ord) }
587
+ end
588
+
589
+ # Escape text for a single-line TOML basic string ("..."). Collapse any newline to a
590
+ # space (single-line context), then the same backslash/quote/control escapes.
591
+ def toml_inline_escape(str)
592
+ s = str.to_s.gsub(/\s*\r?\n\s*/, " ").strip
593
+ s = s.gsub("\\") { "\\\\" }
594
+ s = s.gsub('"') { '\\"' }
595
+ s.gsub(/[\x00-\x08\x0b\x0c\x0e-\x1f]/) { |c| format('\u%04X', c.ord) }
596
+ end
597
+
598
+ def codex_dispatcher_path
599
+ File.join(plastic_home, "scripts", "codex-hook")
600
+ end
601
+
602
+ # ~/.codex/hooks.json merge (intent 102). Guide-settled shape [guide Part 3]:
603
+ # top-level {"hooks": {<Event>: [...]}}, identical to Claude's settings.json
604
+ # hooks shape, so this mirrors merge_claude_hooks against a different file and
605
+ # a different purge predicate (the dispatcher command contains "codex-hook",
606
+ # not the "plastic-" substring merge_claude_hooks matches, since the path is
607
+ # "~/.plastic/scripts/codex-hook", not "~/.claude/hooks/plastic-<name>").
608
+ def merge_codex_hooks(hooks_json_path)
609
+ data = read_json_safe(hooks_json_path) || {}
610
+ hooks = data["hooks"] ||= {}
611
+ purge_stale_codex_hooks(hooks)
612
+ plastic = HookRegistry.codex_hooks_json(dispatcher_path: codex_dispatcher_path)
613
+ plastic.each do |event, groups|
614
+ hooks[event] ||= []
615
+ Array(groups).each { |g| hooks[event] << g }
616
+ end
617
+ write_json_atomic(hooks_json_path, data)
618
+ end
619
+
620
+ def purge_stale_codex_hooks(hooks)
621
+ codex_cmd = ->(cmd) { cmd.to_s.include?("codex-hook") }
622
+
623
+ hooks.each do |event, groups|
624
+ next unless groups.is_a?(Array)
625
+
626
+ hooks[event] = groups.map do |group|
627
+ if group.is_a?(Hash) && group["hooks"].is_a?(Array)
628
+ group["hooks"].reject! { |h| codex_cmd.call(h["command"]) }
629
+ group unless group["hooks"].empty?
630
+ elsif group.is_a?(Hash) && group["command"]
631
+ codex_cmd.call(group["command"]) ? nil : group
632
+ else
633
+ group
634
+ end
635
+ end.compact
636
+ end
637
+ end
638
+
474
639
  def install_hermes(config, force)
475
640
  installed = []
476
641
  skills_source = File.join(package_root, "skills")
@@ -691,6 +856,77 @@ class InstallerCore
691
856
  end
692
857
  end
693
858
 
859
+ # --- Codex AGENTS.md marked-section injection (22a/Beads pattern) ---
860
+ # New primitive: markdown marked-section merge, the analog of merge_claude_hooks'
861
+ # JSON read-modify-write for a partial-ownership text file. Three states
862
+ # (create/append/replace), a body freshness hash in the BEGIN marker, atomic
863
+ # writes, and the 22a safety rule: never write when the existing section can't
864
+ # be parsed (a BEGIN marker with no matching END).
865
+
866
+ # Atomic text writer, mirrors write_json_atomic.
867
+ def write_text_atomic(path, content)
868
+ tmp = "#{path}.plastic-tmp.#{Process.pid}"
869
+ File.write(tmp, content)
870
+ File.rename(tmp, path)
871
+ rescue => e
872
+ File.delete(tmp) if tmp && File.exist?(tmp)
873
+ raise e
874
+ end
875
+
876
+ def codex_section(body: CODEX_AGENTS_MD_BODY)
877
+ hash = Digest::SHA256.hexdigest(body)[0, 12]
878
+ "#{CODEX_SECTION_BEGIN_PREFIX} hash:#{hash} -->\n#{body.strip}\n#{CODEX_SECTION_END}\n"
879
+ end
880
+
881
+ # Returns :created / :appended / :replaced / :refused. Never raises on a normal user file.
882
+ def inject_codex_agents_md(path, body: CODEX_AGENTS_MD_BODY)
883
+ section = codex_section(body: body)
884
+
885
+ unless File.exist?(path)
886
+ FileUtils.mkdir_p(File.dirname(path))
887
+ write_text_atomic(path, section)
888
+ return :created
889
+ end
890
+
891
+ content = File.read(path)
892
+ has_begin = content.include?(CODEX_SECTION_BEGIN_PREFIX)
893
+ has_end = content.include?(CODEX_SECTION_END)
894
+
895
+ # 22a safety rule: never write if the existing section cannot be parsed.
896
+ return :refused if has_begin && !has_end
897
+
898
+ if has_begin
899
+ write_text_atomic(path, content.sub(CODEX_SECTION_RE, section))
900
+ :replaced
901
+ else
902
+ base = content.end_with?("\n") ? content : content + "\n"
903
+ write_text_atomic(path, base + "\n" + section)
904
+ :appended
905
+ end
906
+ end
907
+
908
+ # Remove exactly Plastic's managed section from a user-owned AGENTS.md. Preserve all other
909
+ # content. Delete the file only if Plastic created it and nothing else remains. Returns the
910
+ # path when it acted, nil on no-op. Mirrors remove_claude_hooks: dedicated surgical strip,
911
+ # never the manifest whole-file-delete path.
912
+ def strip_codex_section(path)
913
+ return nil unless File.exist?(path)
914
+ content = File.read(path)
915
+ return nil unless content.include?(CODEX_SECTION_BEGIN_PREFIX)
916
+
917
+ # Remove the section plus the single separator newline the append introduced, so a
918
+ # standard user file round-trips byte-identical.
919
+ stripped = content.sub(/\n?#{CODEX_SECTION_RE}/, "")
920
+
921
+ if stripped.strip.empty?
922
+ File.delete(path) # Plastic-created file: nothing else left
923
+ else
924
+ stripped = stripped.rstrip + "\n" # normalize trailing whitespace we may have left
925
+ write_text_atomic(path, stripped)
926
+ end
927
+ path
928
+ end
929
+
694
930
  # --- Uninstall ---
695
931
 
696
932
  def handle_uninstall(uninstall_agents)
@@ -765,6 +1001,19 @@ class InstallerCore
765
1001
  removed.concat(migrate_legacy_plugin(config[:dir]))
766
1002
  end
767
1003
 
1004
+ # Codex: surgically strip Plastic's marked section from the user-owned AGENTS.md
1005
+ # (dedicated pair, never the manifest whole-file-delete path above), plus the
1006
+ # Plastic entries from hooks.json (intent 102).
1007
+ if key == "codex"
1008
+ agents_md = File.join(config[:home_dir], "AGENTS.md")
1009
+ stripped = strip_codex_section(agents_md)
1010
+ removed << stripped if stripped
1011
+
1012
+ hooks_json = File.join(config[:home_dir], "hooks.json")
1013
+ hooks_removed = remove_codex_hooks(hooks_json)
1014
+ removed << hooks_removed if hooks_removed
1015
+ end
1016
+
768
1017
  { success: true, files: removed.size, removed: removed }
769
1018
  end
770
1019
 
@@ -807,6 +1056,38 @@ class InstallerCore
807
1056
  write_json_atomic(settings_path, settings)
808
1057
  end
809
1058
 
1059
+ # Remove exactly Plastic's entries from ~/.codex/hooks.json (intent 102), mirrors
1060
+ # remove_claude_hooks against the Codex file/purge predicate. Returns the path
1061
+ # when it acted (rewritten or deleted), nil on no-op, mirroring strip_codex_section's
1062
+ # convention so the caller only records an actual change.
1063
+ def remove_codex_hooks(hooks_json_path)
1064
+ data = read_json_safe(hooks_json_path)
1065
+ return nil unless data && data["hooks"]
1066
+
1067
+ before = JSON.generate(data)
1068
+
1069
+ data["hooks"].each do |event, groups|
1070
+ next unless groups.is_a?(Array)
1071
+
1072
+ data["hooks"][event] = groups.map do |g|
1073
+ next g unless g.is_a?(Hash) && Array(g["hooks"]).is_a?(Array)
1074
+
1075
+ g["hooks"] = Array(g["hooks"]).reject { |h| h["command"].to_s.include?("codex-hook") }
1076
+ g["hooks"].empty? ? nil : g
1077
+ end.compact
1078
+ end
1079
+ data["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
1080
+
1081
+ return nil if JSON.generate(data) == before # nothing to change: true no-op
1082
+
1083
+ if data["hooks"].empty? && data.keys == ["hooks"]
1084
+ File.delete(hooks_json_path) # Plastic-created and now empty: remove
1085
+ else
1086
+ write_json_atomic(hooks_json_path, data)
1087
+ end
1088
+ hooks_json_path
1089
+ end
1090
+
810
1091
  # --- Utilities ---
811
1092
 
812
1093
  def agent_config(key)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: plastic-roadmap
3
- description: Use when the user wants to plan a delivery batch, order waves of intents, ship a batch of tickets in one go, track a named collection of intents toward a goal, or asks for a "roadmap". Creates and maintains a roadmap file, a delivery-side collection of intents (the counterpart to a release), separate from INDEX.md status tracking.
3
+ description: Use when the user wants to plan a delivery batch, order waves of intents, ship a batch of intents in one go, track a named collection of intents toward a goal, or asks for a "roadmap". Creates and maintains a roadmap file, a delivery-side collection of intents (the counterpart to a release), separate from INDEX.md status tracking.
4
4
  user-invocable: true
5
5
  ---
6
6
 
@@ -4,7 +4,7 @@ description: >-
4
4
  Use when the user wants to continue or resume a roadmap, pick up a mid-flight delivery batch,
5
5
  asks "where is the roadmap", or wants to resume the wave that was shipping, including an
6
6
  indirect ask that never names a roadmap directly (for example "where did that batch of
7
- tickets land"). This is the roadmap route of plastic-continuing: it finds the tier's
7
+ intents land"). This is the roadmap route of plastic-continuing: it finds the tier's
8
8
  mid-flight roadmap, presents its state, then asks how to proceed exactly once.
9
9
  user-invocable: true
10
10
  ---
@@ -26,7 +26,7 @@
26
26
  },
27
27
  {
28
28
  "id": 3, "scope": "triggering", "set": "validation",
29
- "prompt": "where did that batch of tickets land",
29
+ "prompt": "where did that batch of intents land",
30
30
  "expected_output": "Activates plastic-roadmap-continuing (indirect trigger: a roadmap-resume request that never names 'roadmap' or 'continue').",
31
31
  "files": [],
32
32
  "assertions": [