@zalom/plastic 1.0.0-beta.9 → 1.0.1
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 +98 -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 +17 -2
- 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 +30 -7
- 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 +101 -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 +48 -6
- 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 +58 -17
- package/scripts/plastic-lock +164 -0
- package/scripts/read-config +4 -0
- package/scripts/spawn-preamble +36 -4
- package/scripts/update.rb +16 -7
- package/skills/auto/SKILL.md +127 -20
- 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 +88 -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,27 @@ 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/insight-append" => "scripts/insight-append",
|
|
258
|
+
"scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
|
|
213
259
|
"scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
|
|
260
|
+
"scripts/lib/dashboard_banner.rb" => "scripts/lib/dashboard_banner.rb",
|
|
214
261
|
"scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
|
|
215
262
|
"scripts/qmd-sync" => "scripts/qmd-sync",
|
|
216
263
|
"scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
|
|
@@ -218,6 +265,10 @@ class InstallerCore
|
|
|
218
265
|
"scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
|
|
219
266
|
"scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
|
|
220
267
|
"scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
|
|
268
|
+
"scripts/lib/link_suggestions.rb" => "scripts/lib/link_suggestions.rb",
|
|
269
|
+
"scripts/project-links" => "scripts/project-links",
|
|
270
|
+
"scripts/link-suggest" => "scripts/link-suggest",
|
|
271
|
+
"scripts/rebuild-graph" => "scripts/rebuild-graph",
|
|
221
272
|
"scripts/validate-intent" => "scripts/validate-intent",
|
|
222
273
|
"scripts/new-intent" => "scripts/new-intent",
|
|
223
274
|
"scripts/hook-create-gate" => "scripts/hook-create-gate",
|
|
@@ -226,10 +277,12 @@ class InstallerCore
|
|
|
226
277
|
"templates/plan.md" => "templates/plan.md",
|
|
227
278
|
"templates/checklist.md" => "templates/checklist.md",
|
|
228
279
|
"templates/outcome.md" => "templates/outcome.md",
|
|
280
|
+
"templates/revisions.md" => "templates/revisions.md",
|
|
229
281
|
"scripts/spawn-preamble" => "scripts/spawn-preamble",
|
|
230
282
|
"scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
|
|
231
283
|
"scripts/provision-project-store" => "scripts/provision-project-store",
|
|
232
284
|
"scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
|
|
285
|
+
"scripts/lib/preflight.rb" => "scripts/lib/preflight.rb",
|
|
233
286
|
"scripts/install.rb" => "scripts/install.rb",
|
|
234
287
|
"scripts/update.rb" => "scripts/update.rb",
|
|
235
288
|
"scripts/uninstall.rb" => "scripts/uninstall.rb",
|
|
@@ -302,7 +355,7 @@ class InstallerCore
|
|
|
302
355
|
(data["files"] || {}).keys
|
|
303
356
|
end
|
|
304
357
|
|
|
305
|
-
def install_for_agent(key, force)
|
|
358
|
+
def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
|
|
306
359
|
config = agent_config(key)
|
|
307
360
|
return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
|
|
308
361
|
|
|
@@ -315,7 +368,7 @@ class InstallerCore
|
|
|
315
368
|
old_files = manifest_files(manifest_path_for(key, config))
|
|
316
369
|
|
|
317
370
|
result = case key
|
|
318
|
-
when "claude" then install_claude(config, force)
|
|
371
|
+
when "claude" then install_claude(config, force, argv: argv, input: input, reinstall: reinstall)
|
|
319
372
|
when "codex" then install_codex(config, force)
|
|
320
373
|
when "hermes" then install_hermes(config, force)
|
|
321
374
|
end
|
|
@@ -344,7 +397,7 @@ class InstallerCore
|
|
|
344
397
|
removed
|
|
345
398
|
end
|
|
346
399
|
|
|
347
|
-
def install_claude(config, force)
|
|
400
|
+
def install_claude(config, force, argv: [], input: $stdin, reinstall: false)
|
|
348
401
|
hooks_dir = File.join(config[:dir], "hooks")
|
|
349
402
|
skills_root = File.join(config[:dir], "skills")
|
|
350
403
|
plastic_dir = File.join(config[:dir], "plastic")
|
|
@@ -375,7 +428,7 @@ class InstallerCore
|
|
|
375
428
|
installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
|
|
376
429
|
|
|
377
430
|
# Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
|
|
378
|
-
installed += install_agents(File.join(config[:dir], "agents"))
|
|
431
|
+
installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
|
|
379
432
|
|
|
380
433
|
# Write VERSION
|
|
381
434
|
version_file = File.join(plastic_dir, "VERSION")
|
|
@@ -387,7 +440,8 @@ class InstallerCore
|
|
|
387
440
|
|
|
388
441
|
# Merge hooks + statusline into settings.json (no plugin registration)
|
|
389
442
|
settings_path = File.join(config[:dir], "settings.json")
|
|
390
|
-
|
|
443
|
+
choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
|
|
444
|
+
merge_claude_hooks(settings_path, choice: choice)
|
|
391
445
|
|
|
392
446
|
# Write manifest
|
|
393
447
|
manifest_path = File.join(plastic_dir, "manifest.json")
|
|
@@ -400,7 +454,7 @@ class InstallerCore
|
|
|
400
454
|
installed = []
|
|
401
455
|
skills_source = File.join(package_root, "skills")
|
|
402
456
|
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"))
|
|
457
|
+
installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
|
|
404
458
|
|
|
405
459
|
write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
|
|
406
460
|
{ agent: config[:name], success: true, files: installed.size }
|
|
@@ -410,7 +464,7 @@ class InstallerCore
|
|
|
410
464
|
installed = []
|
|
411
465
|
skills_source = File.join(package_root, "skills")
|
|
412
466
|
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"))
|
|
467
|
+
installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
|
|
414
468
|
|
|
415
469
|
write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
|
|
416
470
|
{ agent: config[:name], success: true, files: installed.size }
|
|
@@ -443,18 +497,51 @@ class InstallerCore
|
|
|
443
497
|
# equivalents). Returns the installed destination paths so callers can append
|
|
444
498
|
# them to `installed` before write_manifest (manifest + prune are then automatic).
|
|
445
499
|
# No-op safe: returns [] when the package has no agents dir or it is empty.
|
|
446
|
-
def install_agents(agents_root)
|
|
500
|
+
def install_agents(agents_root, models: {})
|
|
447
501
|
sources = Dir.glob(File.join(package_root, "agents", "*.md"))
|
|
448
502
|
return [] if sources.empty?
|
|
449
503
|
|
|
450
504
|
FileUtils.mkdir_p(agents_root)
|
|
451
505
|
sources.map do |src|
|
|
452
506
|
dest = File.join(agents_root, File.basename(src))
|
|
453
|
-
|
|
507
|
+
basename = File.basename(src, ".md")
|
|
508
|
+
override = models[basename]
|
|
509
|
+
if override
|
|
510
|
+
File.write(dest, rewrite_model_line(File.read(src), override))
|
|
511
|
+
else
|
|
512
|
+
FileUtils.cp(src, dest)
|
|
513
|
+
end
|
|
454
514
|
dest
|
|
455
515
|
end
|
|
456
516
|
end
|
|
457
517
|
|
|
518
|
+
# Rewrite the single top-level `model:` line in a YAML frontmatter block.
|
|
519
|
+
# Only the frontmatter (between the first two `---` fences) is touched.
|
|
520
|
+
def rewrite_model_line(content, model)
|
|
521
|
+
content.sub(/^model:[^\n]*$/, "model: #{model}")
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Resolve per-agent model overrides for this install: project config (when a
|
|
525
|
+
# project dir is known) overlaid on global config. Defaults are NOT included,
|
|
526
|
+
# so unconfigured agents keep their shipped frontmatter.
|
|
527
|
+
def agent_model_overrides(project_dir = nil)
|
|
528
|
+
global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
|
|
529
|
+
project_config =
|
|
530
|
+
if project_dir
|
|
531
|
+
load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml"))
|
|
532
|
+
else
|
|
533
|
+
{}
|
|
534
|
+
end
|
|
535
|
+
AgentModels.override_map(project_config: project_config, global_config: global_config)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def load_config_yaml(path)
|
|
539
|
+
return {} unless File.exist?(path)
|
|
540
|
+
YAML.safe_load(File.read(path)) || {}
|
|
541
|
+
rescue StandardError
|
|
542
|
+
{}
|
|
543
|
+
end
|
|
544
|
+
|
|
458
545
|
# --- Legacy plugin migration ---
|
|
459
546
|
|
|
460
547
|
# Earlier versions registered Plastic as a local marketplace plugin
|
|
@@ -520,7 +607,7 @@ class InstallerCore
|
|
|
520
607
|
|
|
521
608
|
# --- settings.json merge (read-modify-write, never clobber) ---
|
|
522
609
|
|
|
523
|
-
def merge_claude_hooks(settings_path)
|
|
610
|
+
def merge_claude_hooks(settings_path, choice: :plastic)
|
|
524
611
|
settings = read_json_safe(settings_path) || {}
|
|
525
612
|
return if settings.nil?
|
|
526
613
|
|
|
@@ -529,55 +616,9 @@ class InstallerCore
|
|
|
529
616
|
|
|
530
617
|
purge_stale_plastic_hooks(hooks)
|
|
531
618
|
|
|
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
|
-
}
|
|
619
|
+
# Single source of truth (intent 108, D7): registrations live in
|
|
620
|
+
# HookRegistry; this merge only translates them into settings.json.
|
|
621
|
+
plastic_hooks = HookRegistry.claude_settings_hooks(hook_dir: hook_dir)
|
|
581
622
|
|
|
582
623
|
plastic_hooks.each do |event, group|
|
|
583
624
|
hooks[event] ||= []
|
|
@@ -608,7 +649,7 @@ class InstallerCore
|
|
|
608
649
|
File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
|
|
609
650
|
end
|
|
610
651
|
|
|
611
|
-
settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
|
|
652
|
+
settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" } if choice == :plastic
|
|
612
653
|
|
|
613
654
|
# No plugin/marketplace registration: skills are flat personal skills
|
|
614
655
|
# (plastic-<name>/) discovered directly from ~/.claude/skills.
|