@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/PLASTIC.md +44 -3
- package/README.md +5 -0
- package/agents/plastic-advisor.md +56 -0
- package/agents/plastic-enforcer.md +9 -1
- package/agents/plastic-faux-advisor.md +174 -0
- package/agents/plastic-future-intent-researcher.md +1 -0
- package/hooks/statusline +1 -0
- package/package.json +1 -1
- package/scripts/codex-hook +96 -0
- package/scripts/doctor.rb +160 -4
- package/scripts/install.rb +8 -0
- package/scripts/lib/agent_models.rb +54 -9
- package/scripts/lib/apply_patch_envelope.rb +76 -0
- package/scripts/lib/hook_registry.rb +32 -0
- package/scripts/lib/installer_core.rb +381 -16
- package/skills/agent-advisor/SKILL.md +92 -0
- package/skills/agent-advisor/references/advisor-protocol.md +245 -0
- package/skills/auto/SKILL.md +10 -2
- package/skills/install/SKILL.md +30 -2
- package/skills/update/SKILL.md +20 -3
- package/templates/config.yml +31 -6
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
|
-
|
|
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
|
|
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)
|
package/scripts/install.rb
CHANGED
|
@@ -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
|
|
27
|
-
#
|
|
28
|
-
|
|
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)
|
|
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)
|
|
38
|
-
# keys are carried through as-is; install_agents
|
|
39
|
-
# copied file, so they are ignored without
|
|
40
|
-
|
|
41
|
-
|
|
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:)
|