@zalom/plastic 1.0.0-beta.9 → 1.0.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.
Files changed (107) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +398 -82
  3. package/README.md +99 -82
  4. package/agents/plastic-brainstorming.md +12 -9
  5. package/agents/plastic-enforcer.md +52 -12
  6. package/agents/plastic-executor.md +10 -10
  7. package/agents/plastic-future-intent-researcher.md +4 -8
  8. package/agents/plastic-intent-curator.md +12 -14
  9. package/agents/plastic-intent-discovery.md +35 -0
  10. package/agents/plastic-planner.md +15 -10
  11. package/agents/plastic-spec-specialist.md +15 -10
  12. package/bin/plastic.js +7 -3
  13. package/hooks/code-gate +10 -1
  14. package/hooks/hooks.json +28 -3
  15. package/hooks/lock-gate +21 -0
  16. package/hooks/retrieval-gate +10 -0
  17. package/hooks/savepoint-pre +10 -0
  18. package/hooks/statusline +48 -6
  19. package/package.json +2 -1
  20. package/scripts/agent-report +21 -0
  21. package/scripts/dashboard.rb +98 -23
  22. package/scripts/doctor.rb +232 -5
  23. package/scripts/hook-auto-arm +1 -1
  24. package/scripts/hook-bash-gate +22 -4
  25. package/scripts/hook-code-gate +11 -6
  26. package/scripts/hook-continue +17 -0
  27. package/scripts/hook-create-gate +45 -9
  28. package/scripts/hook-gate-check +24 -23
  29. package/scripts/hook-lock-gate +83 -0
  30. package/scripts/hook-retrieval-gate +148 -0
  31. package/scripts/hook-savepoint-pre +32 -0
  32. package/scripts/hook-session-start +1 -1
  33. package/scripts/insight-append +51 -0
  34. package/scripts/install.rb +44 -7
  35. package/scripts/lib/agent_models.rb +43 -0
  36. package/scripts/lib/bridge.rb +924 -84
  37. package/scripts/lib/dashboard_banner.rb +42 -0
  38. package/scripts/lib/hook_registry.rb +95 -0
  39. package/scripts/lib/insights.rb +86 -0
  40. package/scripts/lib/installer_core.rb +100 -60
  41. package/scripts/lib/link_suggestions.rb +319 -0
  42. package/scripts/lib/lock.rb +375 -0
  43. package/scripts/lib/power_tools.rb +22 -21
  44. package/scripts/lib/preflight.rb +79 -0
  45. package/scripts/lib/qmd_sync.rb +15 -0
  46. package/scripts/lib/retrieval_gate.rb +211 -0
  47. package/scripts/lib/worktree.rb +384 -0
  48. package/scripts/link-suggest +213 -0
  49. package/scripts/new-intent +9 -1
  50. package/scripts/plastic-lock +164 -0
  51. package/scripts/read-config +4 -0
  52. package/scripts/spawn-preamble +11 -3
  53. package/scripts/update.rb +16 -7
  54. package/skills/auto/SKILL.md +126 -19
  55. package/skills/auto/references/agent-architecture.md +7 -4
  56. package/skills/auto/references/agent-report-contract.md +36 -1
  57. package/skills/auto/references/end-tail.md +56 -0
  58. package/skills/auto/references/human-report-contract.md +55 -0
  59. package/skills/auto/references/tiers.md +77 -0
  60. package/skills/brainstorming/SKILL.md +7 -34
  61. package/skills/brainstorming/references/design-principles.md +49 -0
  62. package/skills/continuing/SKILL.md +26 -7
  63. package/skills/creating-intent/SKILL.md +13 -28
  64. package/skills/creating-project/SKILL.md +11 -74
  65. package/skills/creating-project/references/project-scaffolding.md +97 -0
  66. package/skills/creating-skills/SKILL.md +65 -0
  67. package/skills/creating-skills/evals/evals.json +108 -0
  68. package/skills/creating-skills/references/agents.md +168 -0
  69. package/skills/creating-skills/references/evals.md +41 -0
  70. package/skills/creating-skills/references/hooks.md +248 -0
  71. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  72. package/skills/creating-skills/references/scripts.md +166 -0
  73. package/skills/creating-skills/references/skills.md +165 -0
  74. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  75. package/skills/dashboard/SKILL.md +13 -11
  76. package/skills/dashboard/references/classification.md +22 -0
  77. package/skills/doctor/SKILL.md +10 -7
  78. package/skills/executing-plan/SKILL.md +4 -4
  79. package/skills/humanizer/SKILL.md +39 -0
  80. package/skills/humanizer/evals/evals.json +70 -0
  81. package/skills/humanizer/references/always-on-snippet.md +9 -0
  82. package/skills/humanizer/references/examples.md +48 -0
  83. package/skills/install/SKILL.md +75 -84
  84. package/skills/intent-curator/SKILL.md +5 -1
  85. package/skills/intent-discovery/SKILL.md +47 -0
  86. package/skills/intent-starting/SKILL.md +130 -0
  87. package/skills/intent-starting/evals/evals.json +117 -0
  88. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  89. package/skills/linking-intents/SKILL.md +48 -12
  90. package/skills/lock/SKILL.md +41 -0
  91. package/skills/managing-index/SKILL.md +6 -0
  92. package/skills/releasing/SKILL.md +44 -29
  93. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  94. package/skills/roadmap/SKILL.md +51 -0
  95. package/skills/roadmap/references/file-format.md +91 -0
  96. package/skills/roadmap/references/operations.md +78 -0
  97. package/skills/uninstall/SKILL.md +29 -11
  98. package/skills/update/SKILL.md +34 -23
  99. package/skills/versions/SKILL.md +27 -12
  100. package/skills/writing-plans/SKILL.md +10 -88
  101. package/skills/writing-plans/references/plan-format.md +102 -0
  102. package/templates/config.yml +8 -0
  103. package/templates/outcome.md +3 -0
  104. package/templates/revisions.md +58 -0
  105. package/templates/roadmap.md +30 -0
  106. package/skills/writing-instructions/SKILL.md +0 -159
  107. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -0,0 +1,42 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Renders a one-line dashboard summary for the hook-owned systemMessage floor
5
+ # (intent 125, Task 6). Pure and dependency-injected, mirroring BootBanner: it
6
+ # takes the already-parsed `dashboard.rb continue --data` JSON payload and
7
+ # returns a single line, with no file I/O and no subprocess calls in here, so
8
+ # it is unit-testable in isolation while the hook feeds it real data.
9
+ module DashboardBanner
10
+ module_function
11
+
12
+ # payload: the Hash from JSON.parse(`dashboard.rb continue --data`), or nil
13
+ # when the subprocess call failed or produced unusable JSON.
14
+ #
15
+ # Returns a single summary line, or nil when the payload has nothing usable
16
+ # (the caller degrades silently in that case, omitting systemMessage).
17
+ def render(payload)
18
+ return nil unless payload.is_a?(Hash)
19
+ counts = payload["counts"]
20
+ return nil unless counts.is_a?(Hash)
21
+ active = counts["active"].to_i
22
+ future = counts["future"].to_i
23
+ line = "Plastic: #{active} active · #{future} next · run /plastic-dashboard to see the board"
24
+ nbt = next_big_thing_id(payload)
25
+ line += " · next big thing: #{nbt}" if nbt
26
+ line
27
+ end
28
+
29
+ # The id of the top-ranked next_big candidate, when the payload's matrix carries
30
+ # exactly the shape dashboard.rb emits (a "next_big" list of {id, ...} hashes,
31
+ # already rank-sorted). Returns nil for any other shape rather than raising.
32
+ def next_big_thing_id(payload)
33
+ matrix = payload["matrix"]
34
+ return nil unless matrix.is_a?(Hash)
35
+ list = matrix["next_big"]
36
+ return nil unless list.is_a?(Array) && !list.empty?
37
+ top = list.first
38
+ return nil unless top.is_a?(Hash)
39
+ id = top["id"].to_s
40
+ id.empty? ? nil : id
41
+ end
42
+ end
@@ -0,0 +1,95 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # HookRegistry: THE single source of truth for Plastic's hook registration
5
+ # (intent 108, D7). Three consumers, none of which may hand-roll matchers:
6
+ # - InstallerCore#merge_claude_hooks builds settings.json entries from it
7
+ # - hooks/hooks.json (legacy plugin surface) is pinned to it by test
8
+ # - doctor's hooks_match_registry check compares live settings against it
9
+ # Change registrations HERE and only here.
10
+ module HookRegistry
11
+ module_function
12
+
13
+ # MCP tools that MUTATE files. Every write/lock/create gate must match them,
14
+ # or symbolic edits bypass the whole gate layer (the universal MCP-edit
15
+ # bypass found in 108's gate inventory).
16
+ SERENA_EDIT_TOOLS = %w[
17
+ mcp__serena__replace_content
18
+ mcp__serena__replace_symbol_body
19
+ mcp__serena__insert_after_symbol
20
+ mcp__serena__insert_before_symbol
21
+ mcp__serena__safe_delete_symbol
22
+ mcp__serena__rename_symbol
23
+ ].freeze
24
+
25
+ WRITE_MATCHER = (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).join("|")
26
+ CREATE_MATCHER = (%w[Write Edit] + SERENA_EDIT_TOOLS).join("|")
27
+
28
+ # event => ordered list of { "matcher" =>, "hooks" => [{ "name" =>, "status" => }] }
29
+ # The name is the hooks/<name> launcher; the flat install ships it as
30
+ # ~/.claude/hooks/plastic-<name>.
31
+ def events
32
+ {
33
+ "SessionStart" => [
34
+ { "matcher" => "", "hooks" => [
35
+ { "name" => "session-start", "status" => "Loading Plastic context..." },
36
+ { "name" => "check-update", "status" => "" },
37
+ ] },
38
+ ],
39
+ "PreCompact" => [
40
+ { "matcher" => "", "hooks" => [
41
+ { "name" => "savepoint", "status" => "Saving Plastic intent state..." },
42
+ ] },
43
+ ],
44
+ "PreToolUse" => [
45
+ { "matcher" => WRITE_MATCHER, "hooks" => [
46
+ { "name" => "code-gate", "status" => "Checking lifecycle gate..." },
47
+ { "name" => "lock-gate", "status" => "Checking lock gate..." },
48
+ ] },
49
+ { "matcher" => "Write|Edit", "hooks" => [
50
+ { "name" => "savepoint-pre", "status" => "Recording stage start..." },
51
+ ] },
52
+ { "matcher" => CREATE_MATCHER, "hooks" => [
53
+ { "name" => "create-gate", "status" => "Checking create gate..." },
54
+ ] },
55
+ { "matcher" => "Bash", "hooks" => [
56
+ { "name" => "bash-gate", "status" => "Checking lifecycle gate..." },
57
+ ] },
58
+ { "matcher" => "Bash|Read|Grep|Glob", "hooks" => [
59
+ { "name" => "retrieval-gate", "status" => "Checking retrieval gate..." },
60
+ ] },
61
+ ],
62
+ "PostToolUse" => [
63
+ { "matcher" => "Write|Edit", "hooks" => [
64
+ { "name" => "gate-check", "status" => "Checking lifecycle gates..." },
65
+ ] },
66
+ ],
67
+ "UserPromptSubmit" => [
68
+ { "matcher" => "", "hooks" => [
69
+ { "name" => "continue", "status" => "Checking for continue..." },
70
+ { "name" => "future-intent-check", "status" => "Checking future intents..." },
71
+ { "name" => "auto-arm", "status" => "Checking auto mode..." },
72
+ { "name" => "qmd-search", "status" => "Searching QMD..." },
73
+ ] },
74
+ ],
75
+ }
76
+ end
77
+
78
+ # The settings.json shape merge_claude_hooks expects: single-group events map
79
+ # to a Hash, multi-group events to an Array (the merge loop handles both).
80
+ def claude_settings_hooks(hook_dir:)
81
+ events.each_with_object({}) do |(event, groups), out|
82
+ mapped = groups.map do |g|
83
+ {
84
+ "matcher" => g["matcher"],
85
+ "hooks" => g["hooks"].map do |h|
86
+ { "type" => "command",
87
+ "command" => File.join(hook_dir, "plastic-#{h['name']}"),
88
+ "statusMessage" => h["status"] }
89
+ end,
90
+ }
91
+ end
92
+ out[event] = mapped.length == 1 ? mapped.first : mapped
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ # insights.rb - the blessed write path for an intent's `## Insights` section
5
+ # (intent 82). A sibling library to bridge.rb so the insight write path stays
6
+ # independent of the savepoint ledger (the spec forbids touching the ledger).
7
+ #
8
+ # Every entry leads with a fixed, machine-parseable prefix
9
+ # `{utc-iso8601} · {stage} · {author}` where the timestamp is `now.utc.iso8601`,
10
+ # the SAME convention `Bridge.append_savepoint_line` uses, so the store has one
11
+ # timestamp convention across both ledgers. Entries are append-only, newest at
12
+ # the BOTTOM (the [[34]] ordering law): the per-entry prefix is not prepending
13
+ # the entry, and existing entries are never reordered.
14
+
15
+ require "time"
16
+ require_relative "bridge"
17
+
18
+ module Insights
19
+ HEADING = "## Insights"
20
+ # The middle dot (U+00B7) with a single space on each side separates the
21
+ # timestamp, stage, and author fields.
22
+ SEPARATOR = " · "
23
+ # A well-formed prefix: UTC ISO8601 timestamp to the second with a trailing
24
+ # `Z`, then a non-empty stage, then a non-empty author, each ` · `-separated.
25
+ PREFIX_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z · [^·]+ · [^·]+/.freeze
26
+
27
+ # Append one insight entry at the BOTTOM of the `## Insights` section of the
28
+ # intent file, creating the section (or the file) if absent. `now:` is the DI
29
+ # seam: defaults to Time.now, tests inject Time.utc(...). Raises ArgumentError
30
+ # if the assembled prefix is not well-formed (guard on write).
31
+ def self.append_insight(intent_dir, text, stage:, author:, now: Time.now)
32
+ prefix = "#{now.utc.iso8601}#{SEPARATOR}#{stage}#{SEPARATOR}#{author}"
33
+ unless valid_insight_prefix?(prefix)
34
+ raise ArgumentError, "malformed insight prefix: #{prefix.inspect}"
35
+ end
36
+
37
+ entry = "#{prefix} — #{text}"
38
+ path = Bridge.intent_file(intent_dir)
39
+ File.write(path, with_entry(File.exist?(path) ? File.read(path) : "", entry))
40
+ entry
41
+ end
42
+
43
+ # Pure validator: true when `line` begins with a well-formed prefix. No IO,
44
+ # no clock. Rejects a date-only prefix, a missing separator, a missing `Z`,
45
+ # and sub-second precision.
46
+ def self.valid_insight_prefix?(line)
47
+ line.is_a?(String) && line.match?(PREFIX_RE)
48
+ end
49
+
50
+ # Insert `entry` at the bottom of the `## Insights` section of `content`,
51
+ # preserving every existing line and their order. Pure string transform.
52
+ def self.with_entry(content, entry)
53
+ lines = content.empty? ? [] : content.split("\n", -1)
54
+ heading_idx = lines.index { |l| l.strip == HEADING }
55
+ return append_new_section(lines, entry) if heading_idx.nil?
56
+
57
+ insert_at = section_end(lines, heading_idx)
58
+ lines.insert(insert_at, entry)
59
+ lines.join("\n")
60
+ end
61
+
62
+ # The insertion index for a new entry: just after the last non-empty content
63
+ # line that belongs to the `## Insights` section (before the next `## `
64
+ # heading or EOF).
65
+ def self.section_end(lines, heading_idx)
66
+ last_content = heading_idx
67
+ idx = heading_idx + 1
68
+ while idx < lines.length
69
+ break if lines[idx].start_with?("## ")
70
+
71
+ last_content = idx unless lines[idx].strip.empty?
72
+ idx += 1
73
+ end
74
+ last_content + 1
75
+ end
76
+
77
+ # Append a fresh `## Insights` section (blank line, heading, entry) to the end
78
+ # of the existing content.
79
+ def self.append_new_section(lines, entry)
80
+ body = lines.join("\n")
81
+ body = body.sub(/\n+\z/, "") unless body.empty?
82
+ pieces = body.empty? ? [] : [body, ""]
83
+ pieces << HEADING << entry
84
+ "#{pieces.join("\n")}\n"
85
+ end
86
+ end
@@ -6,6 +6,8 @@ require "yaml"
6
6
  require "fileutils"
7
7
  require "digest"
8
8
  require "time"
9
+ require_relative "hook_registry"
10
+ require_relative "agent_models"
9
11
 
10
12
  # Shared installer machinery, instantiable with injected package root / store / agent
11
13
  # map so the verb scripts (install/update/uninstall/versions) and their tests can run
@@ -159,6 +161,37 @@ class InstallerCore
159
161
  nums.select { |n| n >= 1 && n <= agents.size }.map { |n| agents[n - 1][:key] }
160
162
  end
161
163
 
164
+ # Resolve whether install should keep the user's existing statusline or switch it
165
+ # to Plastic's. Pure function of (settings file, argv, input, reinstall): no writes,
166
+ # so it stays fully unit-testable apart from merge_claude_hooks.
167
+ def statusline_choice(settings_path, argv: [], input: $stdin, reinstall: false)
168
+ existing_command = read_json_safe(settings_path)&.dig("statusLine", "command").to_s
169
+ return :plastic if existing_command.empty?
170
+ return :plastic if existing_command.include?("plastic-")
171
+
172
+ idx = argv.index("--statusline")
173
+ flag = idx && argv[idx + 1]
174
+ return flag.to_sym if %w[keep plastic].include?(flag)
175
+
176
+ return :keep if reinstall
177
+ return prompt_statusline(input: input) if input.tty?
178
+
179
+ :keep
180
+ end
181
+
182
+ def prompt_statusline(input: $stdin)
183
+ return :keep unless input.tty?
184
+
185
+ puts "An existing statusline was found in your settings.\n\n"
186
+ puts " 1. Keep my statusline (Plastic will not change it)"
187
+ puts " 2. Switch to Plastic's statusline"
188
+ puts
189
+ print "Select (1 or 2, Enter to keep): "
190
+ answer = input.gets&.strip
191
+
192
+ answer == "2" ? :plastic : :keep
193
+ end
194
+
162
195
  # --- Distribution phase ---
163
196
 
164
197
  def distribute(mode)
@@ -195,6 +228,7 @@ class InstallerCore
195
228
  def core_files
196
229
  {
197
230
  "PLASTIC.md" => "PLASTIC.md",
231
+ "PLASTIC-reference.md" => "PLASTIC-reference.md",
198
232
  "deprecations.yml" => "deprecations.yml",
199
233
  "scripts/folgezettel-id" => "scripts/folgezettel-id",
200
234
  "scripts/read-config" => "scripts/read-config",
@@ -203,14 +237,26 @@ class InstallerCore
203
237
  "scripts/hook-continue" => "scripts/hook-continue",
204
238
  "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
205
239
  "scripts/hook-gate-check" => "scripts/hook-gate-check",
240
+ "scripts/hook-savepoint-pre" => "scripts/hook-savepoint-pre",
206
241
  "scripts/hook-qmd-search" => "scripts/hook-qmd-search",
207
242
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
208
243
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
244
+ "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
209
245
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
246
+ "scripts/hook-lock-gate" => "scripts/hook-lock-gate",
210
247
  "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
248
+ "scripts/hook-retrieval-gate" => "scripts/hook-retrieval-gate",
249
+ "scripts/lib/retrieval_gate.rb" => "scripts/lib/retrieval_gate.rb",
211
250
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
212
251
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
252
+ "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
253
+ "scripts/plastic-lock" => "scripts/plastic-lock",
254
+ "scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
255
+ "scripts/agent-report" => "scripts/agent-report",
256
+ "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
257
+ "scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
213
258
  "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
259
+ "scripts/lib/dashboard_banner.rb" => "scripts/lib/dashboard_banner.rb",
214
260
  "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
215
261
  "scripts/qmd-sync" => "scripts/qmd-sync",
216
262
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
@@ -218,6 +264,10 @@ class InstallerCore
218
264
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
219
265
  "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
220
266
  "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
267
+ "scripts/lib/link_suggestions.rb" => "scripts/lib/link_suggestions.rb",
268
+ "scripts/project-links" => "scripts/project-links",
269
+ "scripts/link-suggest" => "scripts/link-suggest",
270
+ "scripts/rebuild-graph" => "scripts/rebuild-graph",
221
271
  "scripts/validate-intent" => "scripts/validate-intent",
222
272
  "scripts/new-intent" => "scripts/new-intent",
223
273
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
@@ -226,10 +276,12 @@ class InstallerCore
226
276
  "templates/plan.md" => "templates/plan.md",
227
277
  "templates/checklist.md" => "templates/checklist.md",
228
278
  "templates/outcome.md" => "templates/outcome.md",
279
+ "templates/revisions.md" => "templates/revisions.md",
229
280
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
230
281
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
231
282
  "scripts/provision-project-store" => "scripts/provision-project-store",
232
283
  "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
284
+ "scripts/lib/preflight.rb" => "scripts/lib/preflight.rb",
233
285
  "scripts/install.rb" => "scripts/install.rb",
234
286
  "scripts/update.rb" => "scripts/update.rb",
235
287
  "scripts/uninstall.rb" => "scripts/uninstall.rb",
@@ -302,7 +354,7 @@ class InstallerCore
302
354
  (data["files"] || {}).keys
303
355
  end
304
356
 
305
- def install_for_agent(key, force)
357
+ def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
306
358
  config = agent_config(key)
307
359
  return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
308
360
 
@@ -315,7 +367,7 @@ class InstallerCore
315
367
  old_files = manifest_files(manifest_path_for(key, config))
316
368
 
317
369
  result = case key
318
- when "claude" then install_claude(config, force)
370
+ when "claude" then install_claude(config, force, argv: argv, input: input, reinstall: reinstall)
319
371
  when "codex" then install_codex(config, force)
320
372
  when "hermes" then install_hermes(config, force)
321
373
  end
@@ -344,7 +396,7 @@ class InstallerCore
344
396
  removed
345
397
  end
346
398
 
347
- def install_claude(config, force)
399
+ def install_claude(config, force, argv: [], input: $stdin, reinstall: false)
348
400
  hooks_dir = File.join(config[:dir], "hooks")
349
401
  skills_root = File.join(config[:dir], "skills")
350
402
  plastic_dir = File.join(config[:dir], "plastic")
@@ -375,7 +427,7 @@ class InstallerCore
375
427
  installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
376
428
 
377
429
  # Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
378
- installed += install_agents(File.join(config[:dir], "agents"))
430
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
379
431
 
380
432
  # Write VERSION
381
433
  version_file = File.join(plastic_dir, "VERSION")
@@ -387,7 +439,8 @@ class InstallerCore
387
439
 
388
440
  # Merge hooks + statusline into settings.json (no plugin registration)
389
441
  settings_path = File.join(config[:dir], "settings.json")
390
- merge_claude_hooks(settings_path)
442
+ choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
443
+ merge_claude_hooks(settings_path, choice: choice)
391
444
 
392
445
  # Write manifest
393
446
  manifest_path = File.join(plastic_dir, "manifest.json")
@@ -400,7 +453,7 @@ class InstallerCore
400
453
  installed = []
401
454
  skills_source = File.join(package_root, "skills")
402
455
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
403
- installed += install_agents(File.join(config[:dir], "agents"))
456
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
404
457
 
405
458
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
406
459
  { agent: config[:name], success: true, files: installed.size }
@@ -410,7 +463,7 @@ class InstallerCore
410
463
  installed = []
411
464
  skills_source = File.join(package_root, "skills")
412
465
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
413
- installed += install_agents(File.join(config[:dir], "agents"))
466
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
414
467
 
415
468
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
416
469
  { agent: config[:name], success: true, files: installed.size }
@@ -443,18 +496,51 @@ class InstallerCore
443
496
  # equivalents). Returns the installed destination paths so callers can append
444
497
  # them to `installed` before write_manifest (manifest + prune are then automatic).
445
498
  # No-op safe: returns [] when the package has no agents dir or it is empty.
446
- def install_agents(agents_root)
499
+ def install_agents(agents_root, models: {})
447
500
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
448
501
  return [] if sources.empty?
449
502
 
450
503
  FileUtils.mkdir_p(agents_root)
451
504
  sources.map do |src|
452
505
  dest = File.join(agents_root, File.basename(src))
453
- FileUtils.cp(src, dest)
506
+ basename = File.basename(src, ".md")
507
+ override = models[basename]
508
+ if override
509
+ File.write(dest, rewrite_model_line(File.read(src), override))
510
+ else
511
+ FileUtils.cp(src, dest)
512
+ end
454
513
  dest
455
514
  end
456
515
  end
457
516
 
517
+ # Rewrite the single top-level `model:` line in a YAML frontmatter block.
518
+ # Only the frontmatter (between the first two `---` fences) is touched.
519
+ def rewrite_model_line(content, model)
520
+ content.sub(/^model:[^\n]*$/, "model: #{model}")
521
+ end
522
+
523
+ # Resolve per-agent model overrides for this install: project config (when a
524
+ # project dir is known) overlaid on global config. Defaults are NOT included,
525
+ # so unconfigured agents keep their shipped frontmatter.
526
+ def agent_model_overrides(project_dir = nil)
527
+ global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
528
+ project_config =
529
+ if project_dir
530
+ load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml"))
531
+ else
532
+ {}
533
+ end
534
+ AgentModels.override_map(project_config: project_config, global_config: global_config)
535
+ end
536
+
537
+ def load_config_yaml(path)
538
+ return {} unless File.exist?(path)
539
+ YAML.safe_load(File.read(path)) || {}
540
+ rescue StandardError
541
+ {}
542
+ end
543
+
458
544
  # --- Legacy plugin migration ---
459
545
 
460
546
  # Earlier versions registered Plastic as a local marketplace plugin
@@ -520,7 +606,7 @@ class InstallerCore
520
606
 
521
607
  # --- settings.json merge (read-modify-write, never clobber) ---
522
608
 
523
- def merge_claude_hooks(settings_path)
609
+ def merge_claude_hooks(settings_path, choice: :plastic)
524
610
  settings = read_json_safe(settings_path) || {}
525
611
  return if settings.nil?
526
612
 
@@ -529,55 +615,9 @@ class InstallerCore
529
615
 
530
616
  purge_stale_plastic_hooks(hooks)
531
617
 
532
- plastic_hooks = {
533
- "SessionStart" => {
534
- "matcher" => "",
535
- "hooks" => [
536
- { "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
537
- { "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
538
- ],
539
- },
540
- "PreCompact" => {
541
- "matcher" => "",
542
- "hooks" => [
543
- { "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
544
- ],
545
- },
546
- # PreToolUse carries TWO plastic groups with distinct matchers: the
547
- # code-gate (Write|Edit|NotebookEdit) and the create-gate (Write only, intent
548
- # 60b). A single group cannot carry two matchers, so this event maps to a
549
- # LIST of groups; the merge loop appends each (idempotent because the purge
550
- # pass removes all prior plastic groups first).
551
- "PreToolUse" => [
552
- {
553
- "matcher" => "Write|Edit|NotebookEdit",
554
- "hooks" => [
555
- { "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
556
- ],
557
- },
558
- {
559
- "matcher" => "Write",
560
- "hooks" => [
561
- { "type" => "command", "command" => "#{hook_dir}/plastic-create-gate", "statusMessage" => "Checking create gate..." },
562
- ],
563
- },
564
- ],
565
- "PostToolUse" => {
566
- "matcher" => "Write|Edit",
567
- "hooks" => [
568
- { "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
569
- ],
570
- },
571
- "UserPromptSubmit" => {
572
- "matcher" => "",
573
- "hooks" => [
574
- { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
575
- { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
576
- { "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
577
- { "type" => "command", "command" => "#{hook_dir}/plastic-qmd-search", "statusMessage" => "Searching QMD..." },
578
- ],
579
- },
580
- }
618
+ # Single source of truth (intent 108, D7): registrations live in
619
+ # HookRegistry; this merge only translates them into settings.json.
620
+ plastic_hooks = HookRegistry.claude_settings_hooks(hook_dir: hook_dir)
581
621
 
582
622
  plastic_hooks.each do |event, group|
583
623
  hooks[event] ||= []
@@ -608,7 +648,7 @@ class InstallerCore
608
648
  File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
609
649
  end
610
650
 
611
- settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
651
+ settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" } if choice == :plastic
612
652
 
613
653
  # No plugin/marketplace registration: skills are flat personal skills
614
654
  # (plastic-<name>/) discovered directly from ~/.claude/skills.