@zalom/plastic 1.0.0-beta.8 → 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 +407 -84
- 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 +404 -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/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +104 -60
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -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 +138 -29
- package/scripts/plastic-lock +164 -0
- package/scripts/project-links +287 -0
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -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-intent/references/lifecycle.md +9 -4
- 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/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- 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
|
@@ -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,17 +237,37 @@ 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",
|
|
263
|
+
"scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
|
|
264
|
+
"scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
|
|
265
|
+
"scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
|
|
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",
|
|
217
271
|
"scripts/validate-intent" => "scripts/validate-intent",
|
|
218
272
|
"scripts/new-intent" => "scripts/new-intent",
|
|
219
273
|
"scripts/hook-create-gate" => "scripts/hook-create-gate",
|
|
@@ -222,10 +276,12 @@ class InstallerCore
|
|
|
222
276
|
"templates/plan.md" => "templates/plan.md",
|
|
223
277
|
"templates/checklist.md" => "templates/checklist.md",
|
|
224
278
|
"templates/outcome.md" => "templates/outcome.md",
|
|
279
|
+
"templates/revisions.md" => "templates/revisions.md",
|
|
225
280
|
"scripts/spawn-preamble" => "scripts/spawn-preamble",
|
|
226
281
|
"scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
|
|
227
282
|
"scripts/provision-project-store" => "scripts/provision-project-store",
|
|
228
283
|
"scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
|
|
284
|
+
"scripts/lib/preflight.rb" => "scripts/lib/preflight.rb",
|
|
229
285
|
"scripts/install.rb" => "scripts/install.rb",
|
|
230
286
|
"scripts/update.rb" => "scripts/update.rb",
|
|
231
287
|
"scripts/uninstall.rb" => "scripts/uninstall.rb",
|
|
@@ -298,7 +354,7 @@ class InstallerCore
|
|
|
298
354
|
(data["files"] || {}).keys
|
|
299
355
|
end
|
|
300
356
|
|
|
301
|
-
def install_for_agent(key, force)
|
|
357
|
+
def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
|
|
302
358
|
config = agent_config(key)
|
|
303
359
|
return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
|
|
304
360
|
|
|
@@ -311,7 +367,7 @@ class InstallerCore
|
|
|
311
367
|
old_files = manifest_files(manifest_path_for(key, config))
|
|
312
368
|
|
|
313
369
|
result = case key
|
|
314
|
-
when "claude" then install_claude(config, force)
|
|
370
|
+
when "claude" then install_claude(config, force, argv: argv, input: input, reinstall: reinstall)
|
|
315
371
|
when "codex" then install_codex(config, force)
|
|
316
372
|
when "hermes" then install_hermes(config, force)
|
|
317
373
|
end
|
|
@@ -340,7 +396,7 @@ class InstallerCore
|
|
|
340
396
|
removed
|
|
341
397
|
end
|
|
342
398
|
|
|
343
|
-
def install_claude(config, force)
|
|
399
|
+
def install_claude(config, force, argv: [], input: $stdin, reinstall: false)
|
|
344
400
|
hooks_dir = File.join(config[:dir], "hooks")
|
|
345
401
|
skills_root = File.join(config[:dir], "skills")
|
|
346
402
|
plastic_dir = File.join(config[:dir], "plastic")
|
|
@@ -371,7 +427,7 @@ class InstallerCore
|
|
|
371
427
|
installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
|
|
372
428
|
|
|
373
429
|
# Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
|
|
374
|
-
installed += install_agents(File.join(config[:dir], "agents"))
|
|
430
|
+
installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
|
|
375
431
|
|
|
376
432
|
# Write VERSION
|
|
377
433
|
version_file = File.join(plastic_dir, "VERSION")
|
|
@@ -383,7 +439,8 @@ class InstallerCore
|
|
|
383
439
|
|
|
384
440
|
# Merge hooks + statusline into settings.json (no plugin registration)
|
|
385
441
|
settings_path = File.join(config[:dir], "settings.json")
|
|
386
|
-
|
|
442
|
+
choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
|
|
443
|
+
merge_claude_hooks(settings_path, choice: choice)
|
|
387
444
|
|
|
388
445
|
# Write manifest
|
|
389
446
|
manifest_path = File.join(plastic_dir, "manifest.json")
|
|
@@ -396,7 +453,7 @@ class InstallerCore
|
|
|
396
453
|
installed = []
|
|
397
454
|
skills_source = File.join(package_root, "skills")
|
|
398
455
|
installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
|
|
399
|
-
installed += install_agents(File.join(config[:dir], "agents"))
|
|
456
|
+
installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
|
|
400
457
|
|
|
401
458
|
write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
|
|
402
459
|
{ agent: config[:name], success: true, files: installed.size }
|
|
@@ -406,7 +463,7 @@ class InstallerCore
|
|
|
406
463
|
installed = []
|
|
407
464
|
skills_source = File.join(package_root, "skills")
|
|
408
465
|
installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
|
|
409
|
-
installed += install_agents(File.join(config[:dir], "agents"))
|
|
466
|
+
installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
|
|
410
467
|
|
|
411
468
|
write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
|
|
412
469
|
{ agent: config[:name], success: true, files: installed.size }
|
|
@@ -439,18 +496,51 @@ class InstallerCore
|
|
|
439
496
|
# equivalents). Returns the installed destination paths so callers can append
|
|
440
497
|
# them to `installed` before write_manifest (manifest + prune are then automatic).
|
|
441
498
|
# No-op safe: returns [] when the package has no agents dir or it is empty.
|
|
442
|
-
def install_agents(agents_root)
|
|
499
|
+
def install_agents(agents_root, models: {})
|
|
443
500
|
sources = Dir.glob(File.join(package_root, "agents", "*.md"))
|
|
444
501
|
return [] if sources.empty?
|
|
445
502
|
|
|
446
503
|
FileUtils.mkdir_p(agents_root)
|
|
447
504
|
sources.map do |src|
|
|
448
505
|
dest = File.join(agents_root, File.basename(src))
|
|
449
|
-
|
|
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
|
|
450
513
|
dest
|
|
451
514
|
end
|
|
452
515
|
end
|
|
453
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
|
+
|
|
454
544
|
# --- Legacy plugin migration ---
|
|
455
545
|
|
|
456
546
|
# Earlier versions registered Plastic as a local marketplace plugin
|
|
@@ -516,7 +606,7 @@ class InstallerCore
|
|
|
516
606
|
|
|
517
607
|
# --- settings.json merge (read-modify-write, never clobber) ---
|
|
518
608
|
|
|
519
|
-
def merge_claude_hooks(settings_path)
|
|
609
|
+
def merge_claude_hooks(settings_path, choice: :plastic)
|
|
520
610
|
settings = read_json_safe(settings_path) || {}
|
|
521
611
|
return if settings.nil?
|
|
522
612
|
|
|
@@ -525,55 +615,9 @@ class InstallerCore
|
|
|
525
615
|
|
|
526
616
|
purge_stale_plastic_hooks(hooks)
|
|
527
617
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
"hooks" => [
|
|
532
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
|
|
533
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
|
|
534
|
-
],
|
|
535
|
-
},
|
|
536
|
-
"PreCompact" => {
|
|
537
|
-
"matcher" => "",
|
|
538
|
-
"hooks" => [
|
|
539
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
|
|
540
|
-
],
|
|
541
|
-
},
|
|
542
|
-
# PreToolUse carries TWO plastic groups with distinct matchers: the
|
|
543
|
-
# code-gate (Write|Edit|NotebookEdit) and the create-gate (Write only, intent
|
|
544
|
-
# 60b). A single group cannot carry two matchers, so this event maps to a
|
|
545
|
-
# LIST of groups; the merge loop appends each (idempotent because the purge
|
|
546
|
-
# pass removes all prior plastic groups first).
|
|
547
|
-
"PreToolUse" => [
|
|
548
|
-
{
|
|
549
|
-
"matcher" => "Write|Edit|NotebookEdit",
|
|
550
|
-
"hooks" => [
|
|
551
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
|
|
552
|
-
],
|
|
553
|
-
},
|
|
554
|
-
{
|
|
555
|
-
"matcher" => "Write",
|
|
556
|
-
"hooks" => [
|
|
557
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-create-gate", "statusMessage" => "Checking create gate..." },
|
|
558
|
-
],
|
|
559
|
-
},
|
|
560
|
-
],
|
|
561
|
-
"PostToolUse" => {
|
|
562
|
-
"matcher" => "Write|Edit",
|
|
563
|
-
"hooks" => [
|
|
564
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
|
|
565
|
-
],
|
|
566
|
-
},
|
|
567
|
-
"UserPromptSubmit" => {
|
|
568
|
-
"matcher" => "",
|
|
569
|
-
"hooks" => [
|
|
570
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
|
|
571
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
|
|
572
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
|
|
573
|
-
{ "type" => "command", "command" => "#{hook_dir}/plastic-qmd-search", "statusMessage" => "Searching QMD..." },
|
|
574
|
-
],
|
|
575
|
-
},
|
|
576
|
-
}
|
|
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)
|
|
577
621
|
|
|
578
622
|
plastic_hooks.each do |event, group|
|
|
579
623
|
hooks[event] ||= []
|
|
@@ -604,7 +648,7 @@ class InstallerCore
|
|
|
604
648
|
File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
|
|
605
649
|
end
|
|
606
650
|
|
|
607
|
-
settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
|
|
651
|
+
settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" } if choice == :plastic
|
|
608
652
|
|
|
609
653
|
# No plugin/marketplace registration: skills are flat personal skills
|
|
610
654
|
# (plastic-<name>/) discovered directly from ~/.claude/skills.
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# LinkSuggestions - support links decided by CONTEXT INFLUENCE (intent 91, D7), not
|
|
5
|
+
# by shared files, shared symbols, or a topic-similarity score. A script cannot judge
|
|
6
|
+
# whether one intent's context influenced another (that is reasoning over meaning), so
|
|
7
|
+
# this helper does NOT grade. It only:
|
|
8
|
+
#
|
|
9
|
+
# - gather: DISCOVERY. Find candidate intents (via an injected candidate-finder)
|
|
10
|
+
# and surface each candidate's `## Intent` + `## Context` as the
|
|
11
|
+
# EVIDENCE an agent reads to judge influence.
|
|
12
|
+
# - record_edge: record a CONFIRMED `sources`/`chain` frontmatter edge AND append a
|
|
13
|
+
# dated line to the subject intent file's `## Insights` section
|
|
14
|
+
# carrying (utc, target, edge, rating, reason).
|
|
15
|
+
# Append-only, frontmatter block only, never a `## Links` line,
|
|
16
|
+
# never a delete. A no-op without confirm: true.
|
|
17
|
+
# - drift: flag any `## Links` wikilink with no matching frontmatter edge.
|
|
18
|
+
#
|
|
19
|
+
# Two systems frame this:
|
|
20
|
+
# - System for Brain: links are tiered by context influence (sources = foundational
|
|
21
|
+
# context that shaped creation; chain = the genuinely delivery-moving context, a
|
|
22
|
+
# HIGH bar; tags = loose theme grouping, not a link). The influence judgement is
|
|
23
|
+
# made by an agent, not here.
|
|
24
|
+
# - System for Work (Convention over Configuration): `## Links` is a derived view of
|
|
25
|
+
# `sources`/`chain`. This helper never authors a `## Links` line and never deletes.
|
|
26
|
+
#
|
|
27
|
+
# Design rules: all collaborators are injected via the constructor (the store dir, a
|
|
28
|
+
# filesystem reader, AND a candidate-finder). No eval, no ENV / global config seam.
|
|
29
|
+
# Reuses IntentValidator.parse_frontmatter; does NOT touch LinksProjection /
|
|
30
|
+
# LinksSection / project-links / doctor.
|
|
31
|
+
|
|
32
|
+
require "time"
|
|
33
|
+
require_relative "intent_validator"
|
|
34
|
+
require_relative "insights"
|
|
35
|
+
|
|
36
|
+
class LinkSuggestions
|
|
37
|
+
# One discovery candidate plus the evidence an agent reads to judge influence.
|
|
38
|
+
Candidate = Struct.new(:id, :basename, :label, :intent, :context, keyword_init: true)
|
|
39
|
+
|
|
40
|
+
# A drift finding: a `## Links` ref with no matching `sources`/`chain` edge.
|
|
41
|
+
Drift = Struct.new(:ref, :detail, keyword_init: true)
|
|
42
|
+
|
|
43
|
+
RATINGS = %w[high medium low].freeze
|
|
44
|
+
|
|
45
|
+
# A minimal default filesystem reader. Injected so tests can substitute an
|
|
46
|
+
# in-memory map; the production path reads real files. No global state.
|
|
47
|
+
class DiskReader
|
|
48
|
+
def directory?(path)
|
|
49
|
+
File.directory?(path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def children(path)
|
|
53
|
+
Dir.children(path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def exist?(path)
|
|
57
|
+
File.exist?(path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read(path)
|
|
61
|
+
File.read(path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def write(path, content)
|
|
65
|
+
File.write(path, content)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Default candidate-finder: a cheap discovery net (NOT a grade) over the loaded
|
|
70
|
+
# nodes - candidates that share a non-project tag, share a `sources` parent/family,
|
|
71
|
+
# or sit at an adjacent id. The CLI may inject a QMD-backed finder instead. Either
|
|
72
|
+
# way this is DISCOVERY ONLY; influence is judged later by an agent.
|
|
73
|
+
class FamilyTagFinder
|
|
74
|
+
def call(subject_id, nodes)
|
|
75
|
+
subject = nodes[subject_id]
|
|
76
|
+
return [] unless subject
|
|
77
|
+
|
|
78
|
+
nodes.keys.select do |other_id|
|
|
79
|
+
next false if other_id == subject_id
|
|
80
|
+
|
|
81
|
+
other = nodes[other_id]
|
|
82
|
+
shares_tag?(subject, other) || shares_family?(subject, other) ||
|
|
83
|
+
adjacent_id?(subject_id, other_id)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def shares_tag?(subject, other)
|
|
90
|
+
!(link_tags(subject) & link_tags(other)).empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def link_tags(node)
|
|
94
|
+
node[:tags].reject { |t| t.start_with?("project-") }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def shares_family?(subject, other)
|
|
98
|
+
return true unless (subject[:sources] & other[:sources]).empty?
|
|
99
|
+
|
|
100
|
+
subject[:sources].include?(other[:id]) || other[:sources].include?(subject[:id])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Adjacent ids (discovery hint only): equal non-numeric prefix with integers
|
|
104
|
+
# differing by 1 (9<->10, 99<->100, 90<->91), or a letter successor at the same
|
|
105
|
+
# depth (66a<->66b). This is NOT a grade; it only widens the candidate net.
|
|
106
|
+
def adjacent_id?(a, b)
|
|
107
|
+
return false if a == b || a.empty? || b.empty?
|
|
108
|
+
|
|
109
|
+
ma = a.match(/\A(.*?)(\d+)\z/)
|
|
110
|
+
mb = b.match(/\A(.*?)(\d+)\z/)
|
|
111
|
+
return (ma[2].to_i - mb[2].to_i).abs == 1 if ma && mb && ma[1] == mb[1]
|
|
112
|
+
|
|
113
|
+
return false unless a.length == b.length
|
|
114
|
+
|
|
115
|
+
a[0..-2] == b[0..-2] && a[-1].match?(/[a-z]/) && b[-1].match?(/[a-z]/) &&
|
|
116
|
+
(a[-1].succ == b[-1] || b[-1].succ == a[-1])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# store_dir - the directory holding `id--slug/` intent folders for ONE store.
|
|
121
|
+
# reader - injected filesystem collaborator (DiskReader by default).
|
|
122
|
+
# finder - injected candidate-finder responding to #call(subject_id, nodes).
|
|
123
|
+
def initialize(store_dir:, reader: DiskReader.new, finder: FamilyTagFinder.new)
|
|
124
|
+
@store_dir = store_dir
|
|
125
|
+
@reader = reader
|
|
126
|
+
@finder = finder
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
attr_reader :store_dir, :reader, :finder
|
|
130
|
+
|
|
131
|
+
# Load every intent in the store as a node Hash keyed by id:
|
|
132
|
+
# { id => { id:, basename:, label:, path:, dir:, sources:[], chain:[], tags:[],
|
|
133
|
+
# links_refs:[], intent:, context: } }
|
|
134
|
+
def load_nodes
|
|
135
|
+
nodes = {}
|
|
136
|
+
return nodes unless reader.directory?(store_dir)
|
|
137
|
+
|
|
138
|
+
reader.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
139
|
+
dir = File.join(store_dir, entry)
|
|
140
|
+
next unless reader.directory?(dir)
|
|
141
|
+
|
|
142
|
+
md = File.join(dir, "#{entry}.md")
|
|
143
|
+
next unless reader.exist?(md)
|
|
144
|
+
|
|
145
|
+
content = reader.read(md)
|
|
146
|
+
fm = IntentValidator.parse_frontmatter_text(content)
|
|
147
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
148
|
+
|
|
149
|
+
id = fm["id"].to_s
|
|
150
|
+
body = IntentValidator.body_of(content)
|
|
151
|
+
nodes[id] = {
|
|
152
|
+
id: id,
|
|
153
|
+
basename: entry,
|
|
154
|
+
label: fm["intent"].to_s.strip,
|
|
155
|
+
path: md,
|
|
156
|
+
dir: dir,
|
|
157
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
158
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
159
|
+
tags: Array(fm["tags"]).map(&:to_s),
|
|
160
|
+
links_refs: links_refs(body),
|
|
161
|
+
intent: section_text(body, "Intent"),
|
|
162
|
+
context: section_text(body, "Context"),
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
nodes
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# DISCOVERY. The candidate intents for `subject_id`, each carrying its Intent +
|
|
169
|
+
# Context as the evidence an agent reads to judge influence. No grading. Sorted by
|
|
170
|
+
# natural id order for stable output.
|
|
171
|
+
def gather(subject_id, nodes: load_nodes)
|
|
172
|
+
return [] unless nodes.key?(subject_id)
|
|
173
|
+
|
|
174
|
+
ids = finder.call(subject_id, nodes)
|
|
175
|
+
ids.uniq.sort_by { |id| natural_key(id) }.filter_map do |id|
|
|
176
|
+
node = nodes[id]
|
|
177
|
+
next unless node
|
|
178
|
+
|
|
179
|
+
Candidate.new(id: id, basename: node[:basename], label: node[:label],
|
|
180
|
+
intent: node[:intent], context: node[:context])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Record a single CONFIRMED edge from `subject_id` to `target_id`:
|
|
185
|
+
# 1. append `target_id` to the subject's frontmatter `sources` or `chain`;
|
|
186
|
+
# 2. append a dated line to the subject's `## Insights` section capturing
|
|
187
|
+
# {utc, target, edge, rating, reason}.
|
|
188
|
+
# Append-only, frontmatter block only. NEVER writes a `## Links` line, NEVER
|
|
189
|
+
# deletes. A no-op (returns false) without confirm: true, so a default run mutates
|
|
190
|
+
# nothing. Returns true when it wrote, false when it declined or the edge existed.
|
|
191
|
+
def record_edge(subject_id, target_id, edge:, rating: nil, reason: nil,
|
|
192
|
+
confirm: false, now: Time.now, nodes: load_nodes)
|
|
193
|
+
return false unless confirm
|
|
194
|
+
return false unless %i[sources chain].include?(edge)
|
|
195
|
+
|
|
196
|
+
subject = nodes[subject_id]
|
|
197
|
+
return false unless subject
|
|
198
|
+
return false if subject[edge].include?(target_id)
|
|
199
|
+
|
|
200
|
+
content = reader.read(subject[:path])
|
|
201
|
+
updated = add_frontmatter_ref(content, edge.to_s, target_id)
|
|
202
|
+
return false if updated == content
|
|
203
|
+
|
|
204
|
+
final = append_link_insight(updated, target_id, edge, rating, reason, now)
|
|
205
|
+
reader.write(subject[:path], final)
|
|
206
|
+
true
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# DRIFT. The `## Links` refs on `subject_id` with no matching `sources`/`chain`
|
|
210
|
+
# frontmatter edge behind them. Fence-skipping is honored so a `[[id]]` inside an
|
|
211
|
+
# example code block is not flagged.
|
|
212
|
+
def drift(subject_id, nodes: load_nodes)
|
|
213
|
+
subject = nodes[subject_id]
|
|
214
|
+
return [] unless subject
|
|
215
|
+
|
|
216
|
+
edges = (subject[:sources] + subject[:chain]).map { |r| bare_ref(r) }
|
|
217
|
+
subject[:links_refs].reject { |r| edges.include?(bare_ref(r)) }.map do |ref|
|
|
218
|
+
Drift.new(ref: ref,
|
|
219
|
+
detail: "`## Links` references #{ref} with no sources/chain edge behind it")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
# Insert one dated link-decision line at the bottom of the subject intent file's
|
|
226
|
+
# `## Insights` section (96 D3: link rationale lives IN the intent file, never a side
|
|
227
|
+
# file). Reuses the blessed Insights writer (intent 82) for section insertion; carries
|
|
228
|
+
# the five fields (utc, target, edge, rating, reason) as a readable line under the 82
|
|
229
|
+
# exact-timestamp prefix. Returns the augmented content for the single caller write.
|
|
230
|
+
def append_link_insight(content, target_id, edge, rating, reason, now)
|
|
231
|
+
prefix = "#{now.utc.iso8601} · Link · link-suggest"
|
|
232
|
+
text = "#{edge} edge to #{target_id} (rating #{rating || "-"}): #{reason || "-"}"
|
|
233
|
+
Insights.with_entry(content, "#{prefix} — #{text}")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Natural sort key for ids so 2 sorts before 10 and 14a groups with 14.
|
|
237
|
+
def natural_key(id)
|
|
238
|
+
id.scan(/\d+|[a-z]+/).map { |part| part.match?(/\d/) ? [0, part.to_i, ""] : [1, 0, part] }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Strip a `store:` prefix from a ref so a cross-store and same-store form compare.
|
|
242
|
+
def bare_ref(ref)
|
|
243
|
+
ref.to_s.include?(":") ? ref.to_s.split(":", 2).last : ref.to_s
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Extract bare-id wikilink refs from a body's `## Links` section. Matches
|
|
247
|
+
# `[[<id>--<slug>|...]]` and `[[<store>:<id>--<slug>|...]]`, yielding the bare id.
|
|
248
|
+
# Reads only the Links section so example fences elsewhere are not scanned.
|
|
249
|
+
def links_refs(body)
|
|
250
|
+
section = section_text(body, "Links")
|
|
251
|
+
return [] if section.empty?
|
|
252
|
+
|
|
253
|
+
section.scan(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/).filter_map do |match|
|
|
254
|
+
t = bare_ref(match.first.strip)
|
|
255
|
+
id = t.split("--", 2).first
|
|
256
|
+
id unless id.nil? || id.empty?
|
|
257
|
+
end.uniq
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The text under the first `## <heading>` heading, up to the next `## ` heading. A
|
|
261
|
+
# deliberately small reader since we only need the section content. Fence lines are
|
|
262
|
+
# skipped so a `## Links` (or any heading) inside an example block is ignored.
|
|
263
|
+
def section_text(body, heading)
|
|
264
|
+
lines = body.to_s.lines
|
|
265
|
+
out = []
|
|
266
|
+
capture = false
|
|
267
|
+
in_fence = false
|
|
268
|
+
lines.each do |line|
|
|
269
|
+
stripped = line.strip
|
|
270
|
+
if stripped.start_with?("```")
|
|
271
|
+
in_fence = !in_fence
|
|
272
|
+
next
|
|
273
|
+
end
|
|
274
|
+
next if in_fence
|
|
275
|
+
|
|
276
|
+
if stripped == "## #{heading}"
|
|
277
|
+
capture = true
|
|
278
|
+
next
|
|
279
|
+
end
|
|
280
|
+
break if capture && stripped.start_with?("## ")
|
|
281
|
+
|
|
282
|
+
out << line if capture
|
|
283
|
+
end
|
|
284
|
+
out.join.strip
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Append a ref to a frontmatter array (`sources` or `chain`), creating the key if
|
|
288
|
+
# absent. Touches ONLY the frontmatter block; the body (including `## Links`) is
|
|
289
|
+
# left byte-identical. Inline-flow arrays (`key: ["a", "b"]`) are extended in place;
|
|
290
|
+
# an absent key is inserted before the closing `---`. Never deletes.
|
|
291
|
+
def add_frontmatter_ref(content, key, ref)
|
|
292
|
+
return content unless content.start_with?("---")
|
|
293
|
+
|
|
294
|
+
parts = content.split("---", 3)
|
|
295
|
+
return content if parts.length < 3
|
|
296
|
+
|
|
297
|
+
fm = parts[1]
|
|
298
|
+
line_re = /^#{Regexp.escape(key)}:\s*(.*)$/
|
|
299
|
+
if fm =~ line_re
|
|
300
|
+
current = Regexp.last_match(1).strip
|
|
301
|
+
new_line = extend_flow_array(key, current, ref)
|
|
302
|
+
fm = fm.sub(line_re, new_line)
|
|
303
|
+
else
|
|
304
|
+
fm = fm.rstrip + "\n#{key}: [\"#{ref}\"]\n"
|
|
305
|
+
end
|
|
306
|
+
"---#{fm}---#{parts[2]}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def extend_flow_array(key, current, ref)
|
|
310
|
+
if current.empty? || current == "[]"
|
|
311
|
+
%(#{key}: ["#{ref}"])
|
|
312
|
+
elsif current.start_with?("[") && current.end_with?("]")
|
|
313
|
+
inner = current[1..-2].strip
|
|
314
|
+
inner.empty? ? %(#{key}: ["#{ref}"]) : %(#{key}: [#{inner}, "#{ref}"])
|
|
315
|
+
else
|
|
316
|
+
%(#{key}: ["#{ref}"])
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|