@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.
@@ -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",
@@ -434,12 +465,16 @@ class InstallerCore
434
465
  installed << dest
435
466
  end
436
467
 
437
- # Copy skills as flat, hyphen-namespaced personal skills (plastic-<name>/)
468
+ # Copy skills as flat, hyphen-namespaced personal skills (plastic-<name>/).
469
+ # advisor.enabled: false skips the agent-advisor skill along with both
470
+ # advisor agents below, so a user who declined the advisor never sees a
471
+ # dead-end skill pointing at nothing installed.
438
472
  skills_source = File.join(package_root, "skills")
439
- installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
473
+ skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
474
+ installed += install_skills_flat(skills_source, skills_root, exclude: skill_exclude) if File.directory?(skills_source)
440
475
 
441
476
  # Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
442
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
477
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
443
478
 
444
479
  # Write VERSION
445
480
  version_file = File.join(plastic_dir, "VERSION")
@@ -464,18 +499,166 @@ class InstallerCore
464
499
  def install_codex(config, force)
465
500
  installed = []
466
501
  skills_source = File.join(package_root, "skills")
467
- 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)
502
+ skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
503
+ installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
504
+ # Codex-scoped overrides only (agents.models.codex.*): a literal Claude
505
+ # model id set under agents.models.claude.* (or the legacy flat form,
506
+ # which resolves as claude) must never reach a Codex TOML.
507
+ installed += generate_codex_agents(File.join(config[:home_dir], "agents"), models: agent_model_overrides(harness: "codex"))
508
+
509
+ # Instruction injection (L1): Plastic standing conventions into ~/.codex/AGENTS.md.
510
+ # Partial-ownership file, so it is NOT manifest-tracked (stripped surgically on uninstall).
511
+ FileUtils.mkdir_p(config[:home_dir])
512
+ inject_codex_agents_md(File.join(config[:home_dir], "AGENTS.md"))
513
+
514
+ # L3 hooks (intent 102): register into ~/.codex/hooks.json (user scope, defeats the
515
+ # worktree bug). Partial-ownership file, so it is merged and NOT manifest-tracked
516
+ # (stripped surgically on uninstall), same treatment as AGENTS.md.
517
+ merge_codex_hooks(File.join(config[:home_dir], "hooks.json"))
469
518
 
470
519
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
471
520
  { agent: config[:name], success: true, files: installed.size }
472
521
  end
473
522
 
523
+ # --- Codex agent TOML generation (intent 102a) ---
524
+ #
525
+ # Codex reads standalone TOML agent files at ~/.codex/agents/*.toml (config[:home_dir]),
526
+ # never the ~/.agents/agents/*.md copy the shared install_agents writes (that root is
527
+ # the cross-tool skills standard, not an agents root). The codex leg therefore generates
528
+ # a whole-file, Plastic-owned .toml per repo agents/*.md instead of copying markdown.
529
+ # The returned paths append to `installed`, so they are manifest-tracked and pruned on
530
+ # uninstall by the manifest whole-file-delete path, exactly like ~/.claude/agents/*.md.
531
+ def generate_codex_agents(agents_root, models: {})
532
+ sources = Dir.glob(File.join(package_root, "agents", "*.md"))
533
+ return [] if sources.empty?
534
+
535
+ FileUtils.mkdir_p(agents_root)
536
+ sources.filter_map do |src|
537
+ basename = File.basename(src, ".md")
538
+ # Codex advisor support is out of scope for this release (intent 185): the
539
+ # owner has not evaluated the Codex reasoning-model ecosystem long enough to
540
+ # judge it. Skip every AgentModels::CONSULTATION_AGENTS file (both
541
+ # plastic-advisor and plastic-faux-advisor) by name, a deliberate and
542
+ # mechanical scope cut tracked at intent 186 (Codex advisor evaluation), not
543
+ # a permanent exclusion and not conditioned on any frontmatter or override
544
+ # value.
545
+ next if AgentModels::CONSULTATION_AGENTS.include?(basename)
546
+
547
+ dest = File.join(agents_root, "#{basename}.toml")
548
+ write_text_atomic(dest, render_codex_agent_toml(src, models[basename]))
549
+ dest
550
+ end
551
+ end
552
+
553
+ # Render one repo agents/*.md into a deterministic Codex agent TOML document. Fixed field
554
+ # order (name, description, one model field, developer_instructions) so regenerate is
555
+ # byte-identical (idempotency).
556
+ def render_codex_agent_toml(source_path, override)
557
+ front, body = split_frontmatter(File.read(source_path))
558
+ name = (front["name"] || File.basename(source_path, ".md")).to_s
559
+ description = (front["description"] || "").to_s
560
+ effective = (override && !override.to_s.empty? ? override : front["model"]).to_s
561
+
562
+ parts = []
563
+ parts << %(name = "#{toml_inline_escape(name)}")
564
+ parts << %(description = "#{toml_inline_escape(description)}")
565
+ parts << codex_model_fields(effective)
566
+ parts << "developer_instructions = \"\"\"\n#{toml_ml_escape(body.strip)}\n\"\"\""
567
+ parts.reject(&:empty?).join("\n") + "\n"
568
+ end
569
+
570
+ # Split a Plastic agent .md into [frontmatter_hash, body]. Tolerant: a file with no
571
+ # frontmatter yields [{}, whole content].
572
+ def split_frontmatter(content)
573
+ if content =~ /\A---\s*\n(.*?)\n---\s*\n?(.*)\z/m
574
+ front = YAML.safe_load($1) rescue {}
575
+ front = {} unless front.is_a?(Hash)
576
+ [front, $2]
577
+ else
578
+ [{}, content]
579
+ end
580
+ end
581
+
582
+ # The single model-selection line. A known tier alias (opus/sonnet/haiku) emits
583
+ # model_reasoning_effort only; any other non-empty value is a literal Codex model id
584
+ # emitted verbatim as `model`. Empty -> no line (the agent inherits the session default).
585
+ def codex_model_fields(effective)
586
+ return "" if effective.nil? || effective.to_s.empty?
587
+ effort = AgentModels.effort_for(effective)
588
+ if effort
589
+ %(model_reasoning_effort = "#{effort}")
590
+ else
591
+ %(model = "#{toml_inline_escape(effective.to_s)}")
592
+ end
593
+ end
594
+
595
+ # Escape arbitrary text for a TOML multi-line basic string ("""..."""). Order matters:
596
+ # normalize line endings, escape backslash FIRST (so introduced escapes are not
597
+ # re-escaped), then EVERY double-quote (which alone prevents any triple-quote delimiter
598
+ # collision), then C0 control chars other than tab/newline.
599
+ def toml_ml_escape(str)
600
+ s = str.to_s.gsub(/\r\n?/, "\n")
601
+ s = s.gsub("\\") { "\\\\" }
602
+ s = s.gsub('"') { '\\"' }
603
+ s.gsub(/[\x00-\x08\x0b\x0c\x0e-\x1f]/) { |c| format('\u%04X', c.ord) }
604
+ end
605
+
606
+ # Escape text for a single-line TOML basic string ("..."). Collapse any newline to a
607
+ # space (single-line context), then the same backslash/quote/control escapes.
608
+ def toml_inline_escape(str)
609
+ s = str.to_s.gsub(/\s*\r?\n\s*/, " ").strip
610
+ s = s.gsub("\\") { "\\\\" }
611
+ s = s.gsub('"') { '\\"' }
612
+ s.gsub(/[\x00-\x08\x0b\x0c\x0e-\x1f]/) { |c| format('\u%04X', c.ord) }
613
+ end
614
+
615
+ def codex_dispatcher_path
616
+ File.join(plastic_home, "scripts", "codex-hook")
617
+ end
618
+
619
+ # ~/.codex/hooks.json merge (intent 102). Guide-settled shape [guide Part 3]:
620
+ # top-level {"hooks": {<Event>: [...]}}, identical to Claude's settings.json
621
+ # hooks shape, so this mirrors merge_claude_hooks against a different file and
622
+ # a different purge predicate (the dispatcher command contains "codex-hook",
623
+ # not the "plastic-" substring merge_claude_hooks matches, since the path is
624
+ # "~/.plastic/scripts/codex-hook", not "~/.claude/hooks/plastic-<name>").
625
+ def merge_codex_hooks(hooks_json_path)
626
+ data = read_json_safe(hooks_json_path) || {}
627
+ hooks = data["hooks"] ||= {}
628
+ purge_stale_codex_hooks(hooks)
629
+ plastic = HookRegistry.codex_hooks_json(dispatcher_path: codex_dispatcher_path)
630
+ plastic.each do |event, groups|
631
+ hooks[event] ||= []
632
+ Array(groups).each { |g| hooks[event] << g }
633
+ end
634
+ write_json_atomic(hooks_json_path, data)
635
+ end
636
+
637
+ def purge_stale_codex_hooks(hooks)
638
+ codex_cmd = ->(cmd) { cmd.to_s.include?("codex-hook") }
639
+
640
+ hooks.each do |event, groups|
641
+ next unless groups.is_a?(Array)
642
+
643
+ hooks[event] = groups.map do |group|
644
+ if group.is_a?(Hash) && group["hooks"].is_a?(Array)
645
+ group["hooks"].reject! { |h| codex_cmd.call(h["command"]) }
646
+ group unless group["hooks"].empty?
647
+ elsif group.is_a?(Hash) && group["command"]
648
+ codex_cmd.call(group["command"]) ? nil : group
649
+ else
650
+ group
651
+ end
652
+ end.compact
653
+ end
654
+ end
655
+
474
656
  def install_hermes(config, force)
475
657
  installed = []
476
658
  skills_source = File.join(package_root, "skills")
477
- installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
478
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
659
+ skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
660
+ installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
661
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
479
662
 
480
663
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
481
664
  { agent: config[:name], success: true, files: installed.size }
@@ -485,12 +668,14 @@ class InstallerCore
485
668
  # directory name -- the only personal-skill namespacing Claude Code supports).
486
669
  # Any top-level underscore-prefixed markdown fragment (e.g. `_active-intent-gate.md`,
487
670
  # `_decision-tables.md`) is a shared non-skill fragment and relocates to ~/.plastic/
488
- # instead, so every skill can read it from one shared location.
489
- def install_skills_flat(skills_source, skills_root)
671
+ # instead, so every skill can read it from one shared location. `exclude` skips
672
+ # named top-level skill directories entirely (intent 185: the agent-advisor skill
673
+ # when advisor.enabled is false).
674
+ def install_skills_flat(skills_source, skills_root, exclude: [])
490
675
  installed = []
491
676
  FileUtils.mkdir_p(skills_root)
492
677
 
493
- Dir.children(skills_source).reject { |e| e.start_with?(".") }.each do |entry|
678
+ Dir.children(skills_source).reject { |e| e.start_with?(".") || exclude.include?(e) }.each do |entry|
494
679
  src = File.join(skills_source, entry)
495
680
  if File.directory?(src)
496
681
  installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"))
@@ -510,10 +695,18 @@ class InstallerCore
510
695
  # equivalents). Returns the installed destination paths so callers can append
511
696
  # them to `installed` before write_manifest (manifest + prune are then automatic).
512
697
  # No-op safe: returns [] when the package has no agents dir or it is empty.
513
- def install_agents(agents_root, models: {})
698
+ # advisor_enabled: false (advisor.enabled config key) skips every
699
+ # AgentModels::CONSULTATION_AGENTS file entirely (both plastic-advisor and
700
+ # plastic-faux-advisor), so a user who declined the advisor never gets either
701
+ # agent installed.
702
+ def install_agents(agents_root, models: {}, advisor_enabled: true)
514
703
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
515
704
  return [] if sources.empty?
516
705
 
706
+ unless advisor_enabled
707
+ sources = sources.reject { |src| AgentModels::CONSULTATION_AGENTS.include?(File.basename(src, ".md")) }
708
+ end
709
+
517
710
  FileUtils.mkdir_p(agents_root)
518
711
  sources.map do |src|
519
712
  dest = File.join(agents_root, File.basename(src))
@@ -535,9 +728,18 @@ class InstallerCore
535
728
  end
536
729
 
537
730
  # Resolve per-agent model overrides for this install: project config (when a
538
- # project dir is known) overlaid on global config. Defaults are NOT included,
539
- # so unconfigured agents keep their shipped frontmatter.
540
- def agent_model_overrides(project_dir = nil)
731
+ # project dir is known) overlaid on global config, scoped to `harness`
732
+ # ("claude" or "codex"). Defaults are NOT included, so unconfigured agents
733
+ # keep their shipped frontmatter.
734
+ #
735
+ # Both advisor agents (plastic-advisor, plastic-faux-advisor) resolve through
736
+ # this SAME generic map, like any other agent: a config author sets
737
+ # agents.models.claude.plastic-advisor (or the legacy flat
738
+ # agents.models.plastic-advisor, read as claude) to point either agent at a
739
+ # different literal model. There is no separate advisor-specific model key;
740
+ # which agent the advisor SKILL routes to by default is a routing decision
741
+ # (advisor.claude.default), never a model-selection one.
742
+ def agent_model_overrides(project_dir = nil, harness: "claude")
541
743
  global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
542
744
  project_config =
543
745
  if project_dir
@@ -545,7 +747,54 @@ class InstallerCore
545
747
  else
546
748
  {}
547
749
  end
548
- AgentModels.override_map(project_config: project_config, global_config: global_config)
750
+ AgentModels.override_map(project_config: project_config, global_config: global_config, harness: harness)
751
+ end
752
+
753
+ # advisor.enabled: project overlays global, missing or malformed counts as
754
+ # enabled (fail-open). Harness-blind: false skips both advisor agents and the
755
+ # agent-advisor skill on every installed harness.
756
+ def advisor_enabled?(project_dir = nil)
757
+ global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
758
+ project_config = project_dir ? load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml")) : {}
759
+ value = project_config.dig("advisor", "enabled")
760
+ value = global_config.dig("advisor", "enabled") if value.nil?
761
+ value != false
762
+ end
763
+
764
+ # Agent-name shorthands for the --advisor flag: the two shipped choices,
765
+ # named for the role (real advisor vs. the cheaper imitation), never a model
766
+ # name.
767
+ ADVISOR_SHORTHANDS = { "real" => "plastic-advisor", "faux" => "plastic-faux-advisor" }.freeze
768
+
769
+ # Write advisor.enabled / advisor.claude.default into the global config.yml
770
+ # from install-time flags. Absent flags change nothing: advisor.enabled
771
+ # defaults to enabled when missing, and advisor.claude.default is left unset
772
+ # (the skill's own fallback chain applies) when missing.
773
+ # --no-advisor -> advisor.enabled: false
774
+ # --advisor VALUE -> advisor.claude.default: VALUE (an agent name, or the
775
+ # shorthand "real"/"faux")
776
+ def apply_config_flags(argv)
777
+ no_advisor = argv.include?("--no-advisor")
778
+ advisor_idx = argv.index("--advisor")
779
+ advisor_value = advisor_idx && argv[advisor_idx + 1]
780
+ return unless no_advisor || advisor_value
781
+
782
+ config_path = File.join(plastic_home, "config.yml")
783
+ config = load_config_yaml(config_path)
784
+
785
+ if no_advisor
786
+ config["advisor"] ||= {}
787
+ config["advisor"]["enabled"] = false
788
+ end
789
+ if advisor_value
790
+ agent_name = ADVISOR_SHORTHANDS[advisor_value] || advisor_value
791
+ config["advisor"] ||= {}
792
+ config["advisor"]["claude"] ||= {}
793
+ config["advisor"]["claude"]["default"] = agent_name
794
+ end
795
+
796
+ FileUtils.mkdir_p(plastic_home)
797
+ File.write(config_path, YAML.dump(config))
549
798
  end
550
799
 
551
800
  def load_config_yaml(path)
@@ -691,6 +940,77 @@ class InstallerCore
691
940
  end
692
941
  end
693
942
 
943
+ # --- Codex AGENTS.md marked-section injection (22a/Beads pattern) ---
944
+ # New primitive: markdown marked-section merge, the analog of merge_claude_hooks'
945
+ # JSON read-modify-write for a partial-ownership text file. Three states
946
+ # (create/append/replace), a body freshness hash in the BEGIN marker, atomic
947
+ # writes, and the 22a safety rule: never write when the existing section can't
948
+ # be parsed (a BEGIN marker with no matching END).
949
+
950
+ # Atomic text writer, mirrors write_json_atomic.
951
+ def write_text_atomic(path, content)
952
+ tmp = "#{path}.plastic-tmp.#{Process.pid}"
953
+ File.write(tmp, content)
954
+ File.rename(tmp, path)
955
+ rescue => e
956
+ File.delete(tmp) if tmp && File.exist?(tmp)
957
+ raise e
958
+ end
959
+
960
+ def codex_section(body: CODEX_AGENTS_MD_BODY)
961
+ hash = Digest::SHA256.hexdigest(body)[0, 12]
962
+ "#{CODEX_SECTION_BEGIN_PREFIX} hash:#{hash} -->\n#{body.strip}\n#{CODEX_SECTION_END}\n"
963
+ end
964
+
965
+ # Returns :created / :appended / :replaced / :refused. Never raises on a normal user file.
966
+ def inject_codex_agents_md(path, body: CODEX_AGENTS_MD_BODY)
967
+ section = codex_section(body: body)
968
+
969
+ unless File.exist?(path)
970
+ FileUtils.mkdir_p(File.dirname(path))
971
+ write_text_atomic(path, section)
972
+ return :created
973
+ end
974
+
975
+ content = File.read(path)
976
+ has_begin = content.include?(CODEX_SECTION_BEGIN_PREFIX)
977
+ has_end = content.include?(CODEX_SECTION_END)
978
+
979
+ # 22a safety rule: never write if the existing section cannot be parsed.
980
+ return :refused if has_begin && !has_end
981
+
982
+ if has_begin
983
+ write_text_atomic(path, content.sub(CODEX_SECTION_RE, section))
984
+ :replaced
985
+ else
986
+ base = content.end_with?("\n") ? content : content + "\n"
987
+ write_text_atomic(path, base + "\n" + section)
988
+ :appended
989
+ end
990
+ end
991
+
992
+ # Remove exactly Plastic's managed section from a user-owned AGENTS.md. Preserve all other
993
+ # content. Delete the file only if Plastic created it and nothing else remains. Returns the
994
+ # path when it acted, nil on no-op. Mirrors remove_claude_hooks: dedicated surgical strip,
995
+ # never the manifest whole-file-delete path.
996
+ def strip_codex_section(path)
997
+ return nil unless File.exist?(path)
998
+ content = File.read(path)
999
+ return nil unless content.include?(CODEX_SECTION_BEGIN_PREFIX)
1000
+
1001
+ # Remove the section plus the single separator newline the append introduced, so a
1002
+ # standard user file round-trips byte-identical.
1003
+ stripped = content.sub(/\n?#{CODEX_SECTION_RE}/, "")
1004
+
1005
+ if stripped.strip.empty?
1006
+ File.delete(path) # Plastic-created file: nothing else left
1007
+ else
1008
+ stripped = stripped.rstrip + "\n" # normalize trailing whitespace we may have left
1009
+ write_text_atomic(path, stripped)
1010
+ end
1011
+ path
1012
+ end
1013
+
694
1014
  # --- Uninstall ---
695
1015
 
696
1016
  def handle_uninstall(uninstall_agents)
@@ -765,6 +1085,19 @@ class InstallerCore
765
1085
  removed.concat(migrate_legacy_plugin(config[:dir]))
766
1086
  end
767
1087
 
1088
+ # Codex: surgically strip Plastic's marked section from the user-owned AGENTS.md
1089
+ # (dedicated pair, never the manifest whole-file-delete path above), plus the
1090
+ # Plastic entries from hooks.json (intent 102).
1091
+ if key == "codex"
1092
+ agents_md = File.join(config[:home_dir], "AGENTS.md")
1093
+ stripped = strip_codex_section(agents_md)
1094
+ removed << stripped if stripped
1095
+
1096
+ hooks_json = File.join(config[:home_dir], "hooks.json")
1097
+ hooks_removed = remove_codex_hooks(hooks_json)
1098
+ removed << hooks_removed if hooks_removed
1099
+ end
1100
+
768
1101
  { success: true, files: removed.size, removed: removed }
769
1102
  end
770
1103
 
@@ -807,6 +1140,38 @@ class InstallerCore
807
1140
  write_json_atomic(settings_path, settings)
808
1141
  end
809
1142
 
1143
+ # Remove exactly Plastic's entries from ~/.codex/hooks.json (intent 102), mirrors
1144
+ # remove_claude_hooks against the Codex file/purge predicate. Returns the path
1145
+ # when it acted (rewritten or deleted), nil on no-op, mirroring strip_codex_section's
1146
+ # convention so the caller only records an actual change.
1147
+ def remove_codex_hooks(hooks_json_path)
1148
+ data = read_json_safe(hooks_json_path)
1149
+ return nil unless data && data["hooks"]
1150
+
1151
+ before = JSON.generate(data)
1152
+
1153
+ data["hooks"].each do |event, groups|
1154
+ next unless groups.is_a?(Array)
1155
+
1156
+ data["hooks"][event] = groups.map do |g|
1157
+ next g unless g.is_a?(Hash) && Array(g["hooks"]).is_a?(Array)
1158
+
1159
+ g["hooks"] = Array(g["hooks"]).reject { |h| h["command"].to_s.include?("codex-hook") }
1160
+ g["hooks"].empty? ? nil : g
1161
+ end.compact
1162
+ end
1163
+ data["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
1164
+
1165
+ return nil if JSON.generate(data) == before # nothing to change: true no-op
1166
+
1167
+ if data["hooks"].empty? && data.keys == ["hooks"]
1168
+ File.delete(hooks_json_path) # Plastic-created and now empty: remove
1169
+ else
1170
+ write_json_atomic(hooks_json_path, data)
1171
+ end
1172
+ hooks_json_path
1173
+ end
1174
+
810
1175
  # --- Utilities ---
811
1176
 
812
1177
  def agent_config(key)
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: plastic-agent-advisor
3
+ description: >-
4
+ Consult the advisor for expensive reasoning: one-way doors, plans, adversarial
5
+ review of a plan or conclusion before an irreversible step, a deadlock after two
6
+ failed attempts, or ranking several plausible options. Use when the user asks for
7
+ a second opinion, a hard design decision, an architecture review, help breaking a
8
+ deadlock, or says "ask the advisor". Also sets which advisor is the default when
9
+ asked ("make Fable my advisor", "switch my advisor", "use the real advisor").
10
+ user-invocable: true
11
+ ---
12
+
13
+ # Agent Advisor
14
+
15
+ Plastic ships two consultation agents, never dispatched by the auto pipeline, summoned
16
+ only when you decide the reasoning is worth buying:
17
+
18
+ - **`plastic-advisor`** ("the real advisor"): the frontier model itself, expensive,
19
+ billed through usage credits. Spawn it for a few rounds on the hardest problem, then
20
+ close the session.
21
+ - **`plastic-faux-advisor`** ("the imitation advisor"): an ordinary model carrying the
22
+ same reasoning discipline inline in its own body, so it reasons the same disciplined
23
+ way at a fraction of the cost. The cheaper default.
24
+
25
+ ## When to consult (and when not to)
26
+
27
+ Buy a consultation for: decisions with one-way doors (architecture, migration order,
28
+ public contracts); turning a goal plus evidence into a step plan with checks;
29
+ adversarial review of your plan or conclusion before an irreversible step; a deadlock
30
+ after two failed attempts where you cannot say why; ranking several plausible options
31
+ when the ordering decides where you spend the next day.
32
+
33
+ Never buy a consultation for: anything a tool can answer (search, reading code, running
34
+ tests, documentation), writing code at volume, confirming a decision you already made,
35
+ style or naming a linter would settle, or anything reversible and cheap you have not
36
+ tried first. The full buy/never-buy list, the tier table, and the entry test live in
37
+ `references/advisor-protocol.md`; read it before writing a brief for the first time in
38
+ a session.
39
+
40
+ ## Routing: which advisor answers
41
+
42
+ 1. Read the harness-scoped config: `advisor.claude.default`. If unset, fall back to
43
+ `advisor.claude.secondary`, then to `plastic-faux-advisor`.
44
+ 2. If the user names which advisor they want ("ask the real one", "use Fable", "ask the
45
+ cheap one"), honor that directly and dispatch `advisor.claude.primary` (default
46
+ `plastic-advisor`) or `advisor.claude.secondary` (default `plastic-faux-advisor`)
47
+ accordingly, overriding step 1 for this consultation only.
48
+ 3. If `advisor.enabled` reads `false`, neither advisor agent nor this skill is
49
+ installed; this step should not be reachable, but if it is, tell the user the
50
+ advisor is disabled and point at "Setting the default" below.
51
+ 4. Dispatch the resolved agent with a brief built per `references/advisor-protocol.md`
52
+ section 4 (natural prose, the block is a completeness check, not a form to fill).
53
+ State TIER (S, M, or L) and EFFORT explicitly; classify low and prove your way up,
54
+ never open high "to be safe".
55
+ 5. Consume the answer per the protocol's section 5: run the Operating Manual's
56
+ five-question self-test on the advisor's plan before executing it. Advice is input,
57
+ not authority; the plan is the advisor's, the outcome is yours.
58
+
59
+ Read the resolved config value with:
60
+
61
+ ```bash
62
+ ruby ~/.plastic/scripts/read-config advisor.claude.default --project <repo>
63
+ ```
64
+
65
+ (Omit `--project` outside a registered project; falls back to the global value.)
66
+
67
+ ## Setting the default advisor
68
+
69
+ When asked to change the default ("make Fable my advisor", "switch my advisor", "use
70
+ the cheaper one by default"), present the two options in plain language and write the
71
+ choice:
72
+
73
+ - **Faux Fable** (`plastic-faux-advisor`, recommended): an ordinary model carrying the
74
+ frontier reasoning instructions. Much cheaper, available on any plan, reasons in the
75
+ same disciplined way.
76
+ - **Fable 5** (`plastic-advisor`): the frontier model itself. The strongest reasoning
77
+ available, billed through usage credits, so summon it for a few rounds and close it.
78
+
79
+ These are the same two options the installer offers at install and update time. Write
80
+ the choice to `advisor.claude.default` in the global `~/.plastic/config.yml` (or the
81
+ project's `.plastic_store/config.yml` when the user scopes the change to one project):
82
+ read the file as YAML, set `advisor.claude.default` to the agent name (`plastic-advisor`
83
+ or `plastic-faux-advisor`, never a model name or nickname), and write it back. Confirm
84
+ the new default back to the user in one line.
85
+
86
+ ## References
87
+
88
+ - `references/advisor-protocol.md`: the full shipped Advisor Protocol (what to buy,
89
+ tiers and effort, the entry test, how to write a brief that earns its cost, the
90
+ answer contract, session economics, anti-patterns). Read it before the first
91
+ consultation in a session; the second consultation in the same advisor thread costs a
92
+ fraction of the first, so keep follow-ups on one thread rather than opening a new one.