@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 +1 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/codex-hook +96 -0
- package/scripts/doctor.rb +160 -4
- package/scripts/lib/agent_models.rb +18 -0
- package/scripts/lib/apply_patch_envelope.rb +76 -0
- package/scripts/lib/hook_registry.rb +32 -0
- package/scripts/lib/installer_core.rb +283 -2
- package/skills/roadmap/SKILL.md +1 -1
- package/skills/roadmap-continuing/SKILL.md +1 -1
- package/skills/roadmap-continuing/evals/evals.json +1 -1
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
|
|
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
|
@@ -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
|
-
|
|
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)
|
|
@@ -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"),
|
|
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 +=
|
|
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)
|
package/skills/roadmap/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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": [
|