@zalom/plastic 1.0.0-alpha.3 → 1.0.0-alpha.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +134 -469
- package/README.md +95 -58
- package/agents/future-intent-researcher.md +1 -1
- package/agents/intent-curator.md +1 -1
- package/bin/plastic.js +57 -0
- package/bin/test +28 -0
- package/deprecations.yml +1 -10
- package/hooks/auto-arm +5 -0
- package/hooks/bash-gate +3 -0
- package/hooks/check-update +12 -8
- package/hooks/code-gate +12 -0
- package/hooks/gate-check +3 -1
- package/hooks/hooks.json +25 -4
- package/hooks/statusline +50 -10
- package/package.json +2 -2
- package/scripts/dashboard.rb +680 -0
- package/scripts/doctor.rb +1227 -0
- package/scripts/hook-auto-arm +51 -0
- package/scripts/hook-bash-gate +41 -0
- package/scripts/hook-code-gate +27 -0
- package/scripts/hook-continue +15 -114
- package/scripts/hook-gate-check +30 -34
- package/scripts/hook-session-start +121 -31
- package/scripts/install.rb +91 -487
- package/scripts/lib/boot_banner.rb +28 -0
- package/scripts/lib/bridge.rb +358 -10
- package/scripts/lib/installer_core.rb +747 -0
- package/scripts/lib/intent_validator.rb +90 -0
- package/scripts/lib/qmd_sync.rb +159 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +92 -0
- package/scripts/select-update-target +93 -0
- package/scripts/uninstall.rb +53 -0
- package/scripts/update.rb +164 -0
- package/scripts/validate-intent +54 -0
- package/scripts/versions.rb +141 -0
- package/skills/_active-intent-gate.md +26 -0
- package/skills/add-project-store/SKILL.md +54 -0
- package/skills/auto/SKILL.md +69 -9
- package/skills/auto/evals/evals.json +92 -0
- package/skills/auto/references/agent-architecture.md +60 -0
- package/skills/brainstorming/SKILL.md +143 -0
- package/skills/brainstorming-grill-me/SKILL.md +5 -5
- package/skills/continuing/SKILL.md +92 -82
- package/skills/continuing/evals/evals.json +136 -0
- package/skills/continuing/references/context-management.md +32 -0
- package/skills/creating-intent/SKILL.md +38 -9
- package/skills/creating-intent/references/lifecycle.md +74 -0
- package/skills/creating-intent/references/wikilinks.md +8 -0
- package/skills/creating-project/SKILL.md +40 -8
- package/skills/creating-project/references/hubs-projects.md +55 -0
- package/skills/dashboard/SKILL.md +121 -0
- package/skills/dashboard/templates/dashboard-global.md +31 -0
- package/skills/dashboard/templates/dashboard-project.md +40 -0
- package/skills/doctor/SKILL.md +159 -0
- package/skills/doctor/references/gates-stuck-detection.md +38 -0
- package/skills/doctor/report.md +100 -0
- package/skills/evaluating-skills/SKILL.md +140 -0
- package/skills/evaluating-skills/assets/eval-template.json +12 -0
- package/skills/evaluating-skills/evals/evals.json +75 -0
- package/skills/evaluating-skills/references/convention-checks.md +76 -0
- package/skills/evaluating-skills/references/eval-methodology.md +154 -0
- package/skills/executing-plan/SKILL.md +5 -3
- package/skills/install/SKILL.md +69 -8
- package/skills/intent-curator/SKILL.md +3 -3
- package/skills/linking-intents/SKILL.md +5 -1
- package/skills/linking-intents/references/zettelkasten.md +33 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/skills/releasing/SKILL.md +119 -18
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +114 -0
- package/skills/savepoint/SKILL.md +46 -37
- package/skills/savepoint/references/context-management.md +32 -0
- package/skills/uninstall/SKILL.md +39 -28
- package/skills/update/SKILL.md +41 -36
- package/skills/versions/SKILL.md +65 -0
- package/skills/writing-instructions/SKILL.md +159 -0
- package/skills/writing-instructions/references/agentskills-spec.md +135 -0
- package/skills/writing-plans/SKILL.md +183 -0
- package/templates/agents.md +16 -0
- package/templates/outcome.md +13 -0
- package/templates/project.yml +5 -0
- package/templates/savepoint.md +14 -13
- package/templates/spec.md +25 -0
- package/bin/install.js +0 -29
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Usage: hook-auto-arm <message>
|
|
6
|
+
# UserPromptSubmit arming (intent 27). Two jobs, both additive context — never blocks:
|
|
7
|
+
# 1. Detect auto-trigger phrases -> steer the agent to invoke plastic-auto.
|
|
8
|
+
# 2. If auto mode is armed and the active intent hasn't reached How -> remind the agent
|
|
9
|
+
# that project-code edits are gate-blocked until plan.md + checklist.md exist.
|
|
10
|
+
# Outputs UserPromptSubmit hookSpecificOutput JSON, or exits silently.
|
|
11
|
+
|
|
12
|
+
require "json"
|
|
13
|
+
require_relative "lib/bridge"
|
|
14
|
+
|
|
15
|
+
message = (ARGV[0] || "").downcase
|
|
16
|
+
parts = []
|
|
17
|
+
|
|
18
|
+
# --- 1. Auto-trigger steering ---
|
|
19
|
+
AUTO_TRIGGERS = ["take it from here", "deliver this", "/plastic-auto", "auto mode"].freeze
|
|
20
|
+
# bare "auto" only as a standalone word to avoid matching "automatic", "automation", etc.
|
|
21
|
+
triggered = AUTO_TRIGGERS.any? { |p| message.include?(p) } || message.match?(/\bauto\b/)
|
|
22
|
+
if triggered
|
|
23
|
+
parts << "Auto-mode request detected. Invoke the plastic-auto skill to deliver the " \
|
|
24
|
+
"active intent autonomously — it arms the lifecycle gate so project code " \
|
|
25
|
+
"cannot be edited before plan.md + checklist.md exist."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# --- 2. Standing gate reminder when armed and pre-How ---
|
|
29
|
+
bridge_data = Bridge.discover_bridge(session: ENV["CLAUDE_SESSION_ID"], cwd: Dir.pwd)
|
|
30
|
+
if bridge_data && bridge_data.dig("build", "auto") == true
|
|
31
|
+
intent = bridge_data["intent"] || {}
|
|
32
|
+
store = intent["store"]; dir = intent["dir"]
|
|
33
|
+
if store && dir
|
|
34
|
+
intent_dir = File.expand_path("#{store}/#{dir}")
|
|
35
|
+
reached_how = File.exist?("#{intent_dir}/plan.md") && File.exist?("#{intent_dir}/checklist.md")
|
|
36
|
+
unless reached_how
|
|
37
|
+
parts << "Auto mode armed on intent #{intent["id"]} — produce spec -> plan -> " \
|
|
38
|
+
"checklist before editing project code. Code edits are gate-blocked until then."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
exit 0 if parts.empty?
|
|
44
|
+
|
|
45
|
+
payload = {
|
|
46
|
+
"hookSpecificOutput" => {
|
|
47
|
+
"hookEventName" => "UserPromptSubmit",
|
|
48
|
+
"additionalContext" => parts.join("\n")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
puts JSON.generate(payload)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# PreToolUse gate for Bash (intent 27a): close the Bash-edit bypass. When auto
|
|
6
|
+
# mode is armed and the active intent has not reached How (plan.md + checklist.md),
|
|
7
|
+
# block Bash commands that WRITE project code outside the store.
|
|
8
|
+
#
|
|
9
|
+
# Reads the Claude Code PreToolUse payload as JSON on STDIN:
|
|
10
|
+
# { "tool_input": { "command": "..." }, "cwd": "..." }
|
|
11
|
+
# Empty / unparseable / no command => exit 0 (allow). Conservatism is the prime
|
|
12
|
+
# directive: when a command's write intent is ambiguous, ALLOW.
|
|
13
|
+
#
|
|
14
|
+
# Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
|
|
15
|
+
|
|
16
|
+
require "json"
|
|
17
|
+
require_relative "lib/bridge"
|
|
18
|
+
|
|
19
|
+
raw = $stdin.read rescue nil
|
|
20
|
+
exit 0 if raw.nil? || raw.strip.empty?
|
|
21
|
+
|
|
22
|
+
payload = JSON.parse(raw) rescue nil
|
|
23
|
+
exit 0 unless payload.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
|
|
26
|
+
exit 0 if command.nil? || command.to_s.strip.empty?
|
|
27
|
+
|
|
28
|
+
cwd = payload["cwd"]
|
|
29
|
+
cwd = Dir.pwd if cwd.nil? || cwd.to_s.empty?
|
|
30
|
+
|
|
31
|
+
# --- Load bridge (shared resolution; stdin session_id -> env -> /tmp scan) ---
|
|
32
|
+
session = payload["session_id"]
|
|
33
|
+
session = ENV["CLAUDE_SESSION_ID"] if session.nil? || session.to_s.empty?
|
|
34
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
|
|
35
|
+
exit 0 unless bridge_data
|
|
36
|
+
|
|
37
|
+
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
|
|
38
|
+
exit 0 unless reason
|
|
39
|
+
|
|
40
|
+
$stderr.puts "PLASTIC GATE — #{reason}"
|
|
41
|
+
exit 2
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Usage: hook-code-gate <file_path>
|
|
6
|
+
# PreToolUse gate (intent 27): when auto mode is armed and the active intent has not
|
|
7
|
+
# reached How (plan.md + checklist.md), block edits to project code outside the store.
|
|
8
|
+
#
|
|
9
|
+
# Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
|
|
10
|
+
# No bridge / auto not armed / How reached / path under ~/.plastic or the intent dir = allow.
|
|
11
|
+
|
|
12
|
+
require_relative "lib/bridge"
|
|
13
|
+
|
|
14
|
+
file_path = ARGV[0]
|
|
15
|
+
exit 0 unless file_path && !file_path.empty?
|
|
16
|
+
|
|
17
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
|
|
18
|
+
|
|
19
|
+
# --- Load bridge (shared resolution; stdin session_id -> env -> /tmp scan) ---
|
|
20
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
21
|
+
exit 0 unless bridge_data
|
|
22
|
+
|
|
23
|
+
reason = Bridge.code_gate_decision(bridge_data, file_path)
|
|
24
|
+
exit 0 unless reason
|
|
25
|
+
|
|
26
|
+
$stderr.puts "PLASTIC GATE — #{reason}"
|
|
27
|
+
exit 2
|
package/scripts/hook-continue
CHANGED
|
@@ -1,130 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# Usage: hook-continue <index_path> <store_root> <mode>
|
|
4
|
-
#
|
|
4
|
+
# Renders the dashboard `continue` cockpit as UserPromptSubmit additionalContext.
|
|
5
|
+
# Exits silently (no output) if the dashboard produces nothing — the bash caller
|
|
6
|
+
# then falls back to a minimal notice.
|
|
5
7
|
|
|
6
8
|
require "json"
|
|
7
|
-
require "
|
|
8
|
-
require "yaml"
|
|
9
|
+
require "open3"
|
|
9
10
|
|
|
10
|
-
index_path,
|
|
11
|
-
exit 0 unless index_path &&
|
|
11
|
+
index_path, _store_root, _mode = ARGV
|
|
12
|
+
exit 0 unless index_path && File.exist?(index_path)
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
dashboard = File.expand_path("dashboard.rb", __dir__)
|
|
15
|
+
exit 0 unless File.exist?(dashboard)
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
future = []
|
|
18
|
-
section = nil
|
|
17
|
+
cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
|
|
18
|
+
exit 0 unless status.success? && !cockpit.strip.empty?
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
elsif line.start_with?("## Future")
|
|
25
|
-
section = :future
|
|
26
|
-
next
|
|
27
|
-
elsif line.start_with?("## ")
|
|
28
|
-
section = nil
|
|
29
|
-
next
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
next unless section && line.strip.start_with?("- [")
|
|
33
|
-
|
|
34
|
-
if section == :active
|
|
35
|
-
active << line.strip
|
|
36
|
-
elsif section == :future
|
|
37
|
-
future << line.strip
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# --- Detect stale future intents ---
|
|
42
|
-
|
|
43
|
-
stale = []
|
|
44
|
-
stale_days = 3
|
|
45
|
-
config_path = "#{store_root}/config.yml"
|
|
46
|
-
if File.exist?(config_path)
|
|
47
|
-
config = YAML.safe_load(File.read(config_path)) rescue {}
|
|
48
|
-
stale_days = config["stale_threshold_days"] || 3
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
future.each do |f|
|
|
52
|
-
if f =~ /store\/([\w-]+)\//
|
|
53
|
-
dir_name = $1
|
|
54
|
-
intent_file = "#{store_root}/store/#{dir_name}/#{dir_name}.md"
|
|
55
|
-
next unless File.exist?(intent_file)
|
|
56
|
-
|
|
57
|
-
content = File.read(intent_file)
|
|
58
|
-
if content =~ /^created:\s*['"]?(\d{4}-\d{2}-\d{2})/
|
|
59
|
-
age = (Date.today - Date.parse($1)).to_i
|
|
60
|
-
if age >= stale_days
|
|
61
|
-
name = f[/\[([^\]]+)\]/, 1] || "unknown"
|
|
62
|
-
stale << { name: name, age: age, entry: f }
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# --- Detect current project ---
|
|
69
|
-
|
|
70
|
-
current_project = nil
|
|
71
|
-
if mode == "global"
|
|
72
|
-
projects_path = "#{store_root}/projects.yml"
|
|
73
|
-
if File.exist?(projects_path)
|
|
74
|
-
projects = YAML.safe_load(File.read(projects_path)) rescue {}
|
|
75
|
-
cwd = Dir.pwd
|
|
76
|
-
(projects["projects"] || {}).each do |slug, info|
|
|
77
|
-
project_path = File.expand_path(info["path"])
|
|
78
|
-
if cwd.start_with?(project_path)
|
|
79
|
-
current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
|
|
80
|
-
break
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# --- Build context ---
|
|
87
|
-
|
|
88
|
-
parts = []
|
|
89
|
-
parts << "PLASTIC CONTINUE — The user wants to resume work.\n"
|
|
90
|
-
|
|
91
|
-
if mode == "global" && current_project
|
|
92
|
-
parts << "Project: #{current_project["slug"]} (#{current_project["path"]})\n"
|
|
93
|
-
parts << "Governing intent: #{current_project["parent"]}\n" if current_project["parent"]
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
if active.any?
|
|
97
|
-
parts << "## Active Intents (work on these first)\n"
|
|
98
|
-
active.each { |a| parts << a }
|
|
99
|
-
parts << ""
|
|
100
|
-
else
|
|
101
|
-
parts << "## No Active Intents\n"
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
if future.any?
|
|
105
|
-
parts << "## Future Intents (offer as next work)\n"
|
|
106
|
-
future.each { |f| parts << f }
|
|
107
|
-
parts << ""
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
if stale.any?
|
|
111
|
-
parts << "## Stale Future Intents\n"
|
|
112
|
-
parts << "These future intents have not been actioned. Ask the user what to do with each:\n"
|
|
113
|
-
stale.each do |s|
|
|
114
|
-
parts << "- #{s[:name]} (#{s[:age]} days old) — options: activate, abandon, or defer to agent (implement / research / ideate)"
|
|
115
|
-
end
|
|
116
|
-
parts << ""
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
parts << "Follow the plastic:continuing skill workflow.\n"
|
|
120
|
-
parts << "1. If active intents exist: read their state and resume\n"
|
|
121
|
-
parts << "2. If no active intents: present future intents as options\n"
|
|
122
|
-
parts << "3. If stale intents exist: surface them and ask user to decide"
|
|
20
|
+
context = cockpit.rstrip +
|
|
21
|
+
"\n\nFollow the plastic-continuing skill workflow to resume: " \
|
|
22
|
+
"if active intents exist, read their state and resume; otherwise offer the " \
|
|
23
|
+
"Value×Effort matrix items above, surfacing any stale (Nd) / ⚑ triage intents."
|
|
123
24
|
|
|
124
25
|
payload = {
|
|
125
26
|
"hookSpecificOutput" => {
|
|
126
27
|
"hookEventName" => "UserPromptSubmit",
|
|
127
|
-
"additionalContext" =>
|
|
28
|
+
"additionalContext" => context
|
|
128
29
|
}
|
|
129
30
|
}
|
|
130
31
|
puts JSON.generate(payload)
|
package/scripts/hook-gate-check
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# encoding: UTF-8
|
|
3
|
-
# Usage: hook-gate-check <file_path>
|
|
3
|
+
# Usage: hook-gate-check <file_path> [session_id]
|
|
4
4
|
# PostToolUse gate enforcement for Plastic intent lifecycle.
|
|
5
5
|
# Checks if a written file triggers a stage-transition gate.
|
|
6
6
|
# Exit 0 = allow (optionally with transition context)
|
|
@@ -12,32 +12,28 @@ require_relative "lib/bridge"
|
|
|
12
12
|
file_path = ARGV[0]
|
|
13
13
|
exit 0 unless file_path && !file_path.empty?
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
session = ENV["CLAUDE_SESSION_ID"]
|
|
18
|
-
bridge_path = nil
|
|
19
|
-
bridge_data = nil
|
|
15
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
bridge_path = Bridge.path(session)
|
|
23
|
-
bridge_data = Bridge.read(session) if File.exist?(bridge_path)
|
|
24
|
-
end
|
|
17
|
+
file_path_abs = File.expand_path(file_path)
|
|
25
18
|
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
end
|
|
19
|
+
# --- Decoupled savepoint ledger (intent 34, hardened in intent 52) ---
|
|
20
|
+
# The savepoint is derived purely from the file path's intent directory, BEFORE
|
|
21
|
+
# any bridge resolution. A missing bridge must never skip the savepoint.
|
|
22
|
+
intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
|
|
23
|
+
if intent_dir_abs
|
|
24
|
+
begin
|
|
25
|
+
Bridge.append_savepoint(intent_dir_abs, file_path_abs)
|
|
26
|
+
rescue StandardError
|
|
27
|
+
# ignore — rebuildable from disk
|
|
36
28
|
end
|
|
37
29
|
end
|
|
38
30
|
|
|
39
|
-
#
|
|
40
|
-
|
|
31
|
+
# --- Find bridge file ---
|
|
32
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
33
|
+
|
|
34
|
+
# No bridge = no active intent = no stage enforcement. Savepoint already handled.
|
|
35
|
+
exit 0 unless bridge_data
|
|
36
|
+
session = bridge_data["session"]
|
|
41
37
|
|
|
42
38
|
# --- Resolve intent directory ---
|
|
43
39
|
|
|
@@ -49,15 +45,14 @@ dir = intent_info["dir"]
|
|
|
49
45
|
intent_dir = "#{store}/#{dir}"
|
|
50
46
|
|
|
51
47
|
# Normalize both paths for comparison
|
|
52
|
-
|
|
53
|
-
file_path_abs = File.expand_path(file_path)
|
|
48
|
+
bridge_intent_dir_abs = File.expand_path(intent_dir)
|
|
54
49
|
|
|
55
50
|
# Not inside intent dir = not our business
|
|
56
|
-
exit 0 unless file_path_abs.start_with?("#{
|
|
51
|
+
exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
|
|
57
52
|
|
|
58
53
|
# --- Determine if this is a stage-transition file ---
|
|
59
54
|
|
|
60
|
-
relative = file_path_abs.sub("#{
|
|
55
|
+
relative = file_path_abs.sub("#{bridge_intent_dir_abs}/", "")
|
|
61
56
|
basename = File.basename(file_path)
|
|
62
57
|
|
|
63
58
|
stage_files = %w[spec.md plan.md checklist.md outcome.md]
|
|
@@ -66,7 +61,7 @@ is_action_file = relative.start_with?("actions/")
|
|
|
66
61
|
|
|
67
62
|
if is_stage_file || is_action_file
|
|
68
63
|
# Run gate check
|
|
69
|
-
error = Bridge.check_gate(
|
|
64
|
+
error = Bridge.check_gate(bridge_intent_dir_abs, file_path_abs)
|
|
70
65
|
|
|
71
66
|
if error
|
|
72
67
|
# Gate violation — block the write
|
|
@@ -83,11 +78,11 @@ if is_stage_file || is_action_file
|
|
|
83
78
|
|
|
84
79
|
# Gate passed — update bridge with new stage
|
|
85
80
|
old_stage = bridge_data["build"]["stage"]
|
|
86
|
-
new_stage = Bridge.derive_stage(
|
|
87
|
-
new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(
|
|
81
|
+
new_stage = Bridge.derive_stage(bridge_intent_dir_abs)
|
|
82
|
+
new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(bridge_intent_dir_abs)
|
|
88
83
|
|
|
89
84
|
bridge_data["build"]["stage"] = new_stage
|
|
90
|
-
bridge_data["build"]["has"] = Bridge.has_files(
|
|
85
|
+
bridge_data["build"]["has"] = Bridge.has_files(bridge_intent_dir_abs)
|
|
91
86
|
bridge_data["build"]["missing"] = new_missing
|
|
92
87
|
bridge_data["build"]["gate_failures"] = 0
|
|
93
88
|
bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
|
|
@@ -102,9 +97,9 @@ if is_stage_file || is_action_file
|
|
|
102
97
|
stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
|
|
103
98
|
next_hints = {
|
|
104
99
|
"why" => "write spec.md",
|
|
105
|
-
"how" => "Why complete. Invoke plastic
|
|
106
|
-
"exec" => "How complete. Invoke plastic
|
|
107
|
-
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic
|
|
100
|
+
"how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
|
|
101
|
+
"exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
|
|
102
|
+
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
|
|
108
103
|
}
|
|
109
104
|
|
|
110
105
|
context_parts = ["PLASTIC"]
|
|
@@ -129,7 +124,8 @@ if is_stage_file || is_action_file
|
|
|
129
124
|
puts JSON.generate(payload)
|
|
130
125
|
exit 0
|
|
131
126
|
else
|
|
132
|
-
# Regular file in intent dir — just update last_activity
|
|
127
|
+
# Regular file in intent dir — just update last_activity. The savepoint for
|
|
128
|
+
# the What milestone was already handled above by the decoupled ledger write.
|
|
133
129
|
bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
|
|
134
130
|
Bridge.write(session, bridge_data)
|
|
135
131
|
exit 0
|
|
@@ -7,6 +7,9 @@ require "json"
|
|
|
7
7
|
require "date"
|
|
8
8
|
require "yaml"
|
|
9
9
|
require_relative "lib/bridge"
|
|
10
|
+
require_relative "lib/boot_banner"
|
|
11
|
+
require_relative "lib/qmd_sync"
|
|
12
|
+
require_relative "doctor"
|
|
10
13
|
|
|
11
14
|
index_path, store_root, mode, plugin_root = ARGV
|
|
12
15
|
exit 0 unless index_path && store_root && mode
|
|
@@ -117,15 +120,23 @@ if File.exist?(projects_path)
|
|
|
117
120
|
end
|
|
118
121
|
end
|
|
119
122
|
|
|
123
|
+
# --- Load PLASTIC.md conventions ---
|
|
124
|
+
|
|
125
|
+
plastic_md_path = "#{store_root}/PLASTIC.md"
|
|
126
|
+
plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
|
|
127
|
+
|
|
120
128
|
# --- Load deprecations ---
|
|
121
129
|
|
|
130
|
+
dep_file = if plugin_root && !plugin_root.empty?
|
|
131
|
+
"#{plugin_root}/deprecations.yml"
|
|
132
|
+
else
|
|
133
|
+
"#{store_root}/deprecations.yml"
|
|
134
|
+
end
|
|
135
|
+
|
|
122
136
|
deprecations = []
|
|
123
|
-
if
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
dep_data = YAML.safe_load(File.read(dep_file)) rescue {}
|
|
127
|
-
deprecations = dep_data["deprecations"] || []
|
|
128
|
-
end
|
|
137
|
+
if File.exist?(dep_file)
|
|
138
|
+
dep_data = YAML.safe_load(File.read(dep_file)) rescue {}
|
|
139
|
+
deprecations = dep_data["deprecations"] || []
|
|
129
140
|
end
|
|
130
141
|
|
|
131
142
|
dismissed_json = `"#{read_config}" deprecations_dismissed`.strip
|
|
@@ -136,7 +147,10 @@ rescue
|
|
|
136
147
|
end
|
|
137
148
|
|
|
138
149
|
current_version = nil
|
|
139
|
-
|
|
150
|
+
version_file = "#{store_root}/VERSION"
|
|
151
|
+
if File.exist?(version_file)
|
|
152
|
+
current_version = File.read(version_file).strip
|
|
153
|
+
elsif plugin_root && !plugin_root.empty?
|
|
140
154
|
plugin_json_path = "#{plugin_root}/.claude-plugin/plugin.json"
|
|
141
155
|
if File.exist?(plugin_json_path)
|
|
142
156
|
pj = JSON.parse(File.read(plugin_json_path)) rescue {}
|
|
@@ -150,42 +164,105 @@ active_deprecations = deprecations.select do |dep|
|
|
|
150
164
|
!dismissed.include?(dep["id"])
|
|
151
165
|
end
|
|
152
166
|
|
|
153
|
-
# ---
|
|
167
|
+
# --- Check for available updates (from previous session's check) ---
|
|
168
|
+
|
|
169
|
+
update_notice = nil
|
|
170
|
+
cache_file = "#{store_root}/.cache/update-check.json"
|
|
171
|
+
if File.exist?(cache_file)
|
|
172
|
+
cache = JSON.parse(File.read(cache_file)) rescue {}
|
|
173
|
+
if cache["updateAvailable"]
|
|
174
|
+
update_notice = "Plastic update available: #{cache["current"]} -> #{cache["latest"]} — run /plastic-update"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# --- Run core health in-process (intent 36a) ---
|
|
179
|
+
# Reuse Doctor's own --core checks so there is one source of truth for "core
|
|
180
|
+
# health" (no second process spawn, no duplicated check list). Never blocks the
|
|
181
|
+
# session: any failure or exception degrades to a banner and we continue.
|
|
182
|
+
|
|
183
|
+
core_health = begin
|
|
184
|
+
Doctor.new(plastic_home: store_root).run_core_checks("claude")
|
|
185
|
+
rescue
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
core_banner = BootBanner.render(health: core_health, version: current_version)
|
|
189
|
+
|
|
190
|
+
# --- Assemble session context ---
|
|
154
191
|
|
|
155
192
|
all_active = active + project_active
|
|
156
193
|
all_future = future + project_future
|
|
157
|
-
exit 0 if all_active.empty? && stale.empty? && all_future.empty? && active_deprecations.empty?
|
|
158
194
|
|
|
159
195
|
# --- Build context ---
|
|
196
|
+
# PLASTIC.md conventions and intent context render only when conventions are
|
|
197
|
+
# installed. Deprecation warnings and update notices render regardless, so a
|
|
198
|
+
# partial install (or a missing PLASTIC.md) still surfaces critical warnings.
|
|
160
199
|
|
|
161
200
|
parts = []
|
|
162
201
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
202
|
+
# Boot banner first: every session start surfaces that Plastic loaded, with the
|
|
203
|
+
# version (clean) or the first failing core check (degraded). Owned by the hook
|
|
204
|
+
# so it runs by construction — the plastic-continuing skill no longer does this.
|
|
205
|
+
parts << core_banner
|
|
206
|
+
parts << ""
|
|
168
207
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
208
|
+
# --- QMD search status (intent 45a, READ-ONLY) ---
|
|
209
|
+
# Report-only line for the model (additionalContext), never systemMessage and
|
|
210
|
+
# never the exit code. QmdSync.status shells out to `qmd collection list`, so the
|
|
211
|
+
# whole block is guarded: a 2s timeout caps any hang, and rescue-all guarantees a
|
|
212
|
+
# slow/broken/missing qmd appends nothing and the hook continues cleanly.
|
|
213
|
+
begin
|
|
214
|
+
require "timeout"
|
|
215
|
+
qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: store_root) }
|
|
216
|
+
if qmd_status[:present]
|
|
217
|
+
if qmd_status[:all_registered]
|
|
218
|
+
parts << "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
|
|
219
|
+
else
|
|
220
|
+
parts << "QMD detected — run `qmd-sync register --all` to index your Plastic stores for search."
|
|
221
|
+
end
|
|
177
222
|
end
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
parts << "No active intents. #{future.length} future intents available.\n"
|
|
223
|
+
rescue Exception
|
|
224
|
+
# Any failure (timeout, missing binary, parse error) — stay silent, never crash.
|
|
181
225
|
end
|
|
182
226
|
|
|
183
|
-
if
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
227
|
+
if plastic_md
|
|
228
|
+
# Conventions always loaded first
|
|
229
|
+
parts << plastic_md
|
|
230
|
+
parts << "\n---\n"
|
|
231
|
+
|
|
232
|
+
if current_project
|
|
233
|
+
slug = current_project["slug"]
|
|
234
|
+
banner = "Project: #{slug} | Store: ~/.plastic/projects/#{slug}/store/"
|
|
235
|
+
if project_active.any? && project_active.first =~ /\[([^\]]+)\].*store\/([\w-]+)\//
|
|
236
|
+
intent_name, dir_name = $1, $2
|
|
237
|
+
intent_id = dir_name.split("--").first
|
|
238
|
+
banner += "\nActive: [#{intent_id} — #{intent_name}] | Artifacts → store/#{dir_name}/"
|
|
239
|
+
end
|
|
240
|
+
parts << banner + "\n"
|
|
241
|
+
else
|
|
242
|
+
parts << "PLASTIC — Global store loaded from ~/.plastic/\n"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if all_active.any?
|
|
246
|
+
parts << "Active intents:\n"
|
|
247
|
+
all_active.each { |a| parts << a }
|
|
248
|
+
if bridge_data
|
|
249
|
+
stage = bridge_data["build"]["stage"]
|
|
250
|
+
missing = bridge_data["build"]["missing"]
|
|
251
|
+
missing_str = missing.empty? ? "none" : missing.join(", ")
|
|
252
|
+
parts << "Stage: #{stage} | Next: #{missing_str}"
|
|
253
|
+
end
|
|
254
|
+
parts << ""
|
|
255
|
+
else
|
|
256
|
+
parts << "No active intents. #{future.length} future intents available.\n"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
if stale.any?
|
|
260
|
+
parts << "\nStale future intents (untouched for days):\n"
|
|
261
|
+
stale.each do |s|
|
|
262
|
+
parts << "- #{s[:name]} (#{s[:age]} days) — consider: activate, abandon, or defer to agent"
|
|
263
|
+
end
|
|
264
|
+
parts << "\nWhen appropriate, ask the user what to do with stale intents."
|
|
187
265
|
end
|
|
188
|
-
parts << "\nWhen appropriate, ask the user what to do with stale intents."
|
|
189
266
|
end
|
|
190
267
|
|
|
191
268
|
if active_deprecations.any?
|
|
@@ -215,10 +292,23 @@ if active_deprecations.any?
|
|
|
215
292
|
end
|
|
216
293
|
end
|
|
217
294
|
|
|
295
|
+
if update_notice
|
|
296
|
+
parts.unshift("! #{update_notice}\n")
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Emit nothing when there is genuinely nothing to surface (no conventions,
|
|
300
|
+
# no deprecations, no update notice).
|
|
301
|
+
exit 0 if parts.join.strip.empty?
|
|
302
|
+
|
|
218
303
|
payload = {
|
|
219
304
|
"hookSpecificOutput" => {
|
|
220
305
|
"hookEventName" => "SessionStart",
|
|
221
306
|
"additionalContext" => parts.join("\n")
|
|
222
|
-
}
|
|
307
|
+
},
|
|
308
|
+
# Intent 54: additionalContext is model-only, so the banner stays invisible to
|
|
309
|
+
# the human. The top-level systemMessage channel is rendered in the user's
|
|
310
|
+
# terminal (and re-fires on /clear). Reuse the same BootBanner line so the
|
|
311
|
+
# visible banner and the model-facing banner cannot drift.
|
|
312
|
+
"systemMessage" => core_banner
|
|
223
313
|
}
|
|
224
314
|
puts JSON.generate(payload)
|