@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
|
@@ -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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
468
|
-
installed +=
|
|
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
|
-
|
|
478
|
-
installed +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
539
|
-
#
|
|
540
|
-
|
|
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.
|