@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.
- package/PLASTIC-reference.md +133 -0
- package/PLASTIC.md +398 -82
- package/README.md +99 -82
- package/agents/plastic-brainstorming.md +12 -9
- package/agents/plastic-enforcer.md +52 -12
- package/agents/plastic-executor.md +10 -10
- package/agents/plastic-future-intent-researcher.md +4 -8
- package/agents/plastic-intent-curator.md +12 -14
- package/agents/plastic-intent-discovery.md +35 -0
- package/agents/plastic-planner.md +15 -10
- package/agents/plastic-spec-specialist.md +15 -10
- package/bin/plastic.js +7 -3
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +28 -3
- package/hooks/lock-gate +21 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +48 -6
- package/package.json +2 -1
- package/scripts/agent-report +21 -0
- package/scripts/dashboard.rb +98 -23
- package/scripts/doctor.rb +232 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-continue +17 -0
- package/scripts/hook-create-gate +45 -9
- package/scripts/hook-gate-check +24 -23
- package/scripts/hook-lock-gate +83 -0
- package/scripts/hook-retrieval-gate +148 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/install.rb +44 -7
- package/scripts/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +924 -84
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +100 -60
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +22 -21
- package/scripts/lib/preflight.rb +79 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +384 -0
- package/scripts/link-suggest +213 -0
- package/scripts/new-intent +9 -1
- package/scripts/plastic-lock +164 -0
- package/scripts/read-config +4 -0
- package/scripts/spawn-preamble +11 -3
- package/scripts/update.rb +16 -7
- package/skills/auto/SKILL.md +126 -19
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/auto/references/end-tail.md +56 -0
- package/skills/auto/references/human-report-contract.md +55 -0
- package/skills/auto/references/tiers.md +77 -0
- package/skills/brainstorming/SKILL.md +7 -34
- package/skills/brainstorming/references/design-principles.md +49 -0
- package/skills/continuing/SKILL.md +26 -7
- package/skills/creating-intent/SKILL.md +13 -28
- package/skills/creating-project/SKILL.md +11 -74
- package/skills/creating-project/references/project-scaffolding.md +97 -0
- package/skills/creating-skills/SKILL.md +65 -0
- package/skills/creating-skills/evals/evals.json +108 -0
- package/skills/creating-skills/references/agents.md +168 -0
- package/skills/creating-skills/references/evals.md +41 -0
- package/skills/creating-skills/references/hooks.md +248 -0
- package/skills/creating-skills/references/progressive-disclosure.md +176 -0
- package/skills/creating-skills/references/scripts.md +166 -0
- package/skills/creating-skills/references/skills.md +165 -0
- package/skills/creating-skills/scripts/scaffold.rb +313 -0
- package/skills/dashboard/SKILL.md +13 -11
- package/skills/dashboard/references/classification.md +22 -0
- package/skills/doctor/SKILL.md +10 -7
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/humanizer/SKILL.md +39 -0
- package/skills/humanizer/evals/evals.json +70 -0
- package/skills/humanizer/references/always-on-snippet.md +9 -0
- package/skills/humanizer/references/examples.md +48 -0
- package/skills/install/SKILL.md +75 -84
- package/skills/intent-curator/SKILL.md +5 -1
- package/skills/intent-discovery/SKILL.md +47 -0
- package/skills/intent-starting/SKILL.md +130 -0
- package/skills/intent-starting/evals/evals.json +117 -0
- package/skills/intent-starting/references/boarding-matrix.md +35 -0
- package/skills/linking-intents/SKILL.md +48 -12
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/releasing/SKILL.md +44 -29
- package/skills/releasing/references/promotion-and-tagging.md +60 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roadmap/references/file-format.md +91 -0
- package/skills/roadmap/references/operations.md +78 -0
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
- package/skills/writing-plans/SKILL.md +10 -88
- package/skills/writing-plans/references/plan-format.md +102 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/templates/roadmap.md +30 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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.
|