@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.2
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 +163 -469
- package/README.md +95 -58
- package/agents/plastic-brainstorming.md +37 -0
- package/agents/plastic-enforcer.md +36 -0
- package/agents/plastic-executor.md +37 -0
- package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
- package/agents/{intent-curator.md → plastic-intent-curator.md} +1 -1
- package/agents/plastic-planner.md +37 -0
- package/agents/plastic-spec-specialist.md +37 -0
- 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/create-gate +3 -0
- package/hooks/gate-check +3 -1
- package/hooks/hooks.json +52 -0
- package/hooks/qmd-search +8 -0
- package/package.json +2 -2
- package/scripts/dashboard.rb +687 -0
- package/scripts/doctor.rb +1054 -628
- 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-create-gate +59 -0
- package/scripts/hook-gate-check +47 -32
- package/scripts/hook-qmd-search +44 -0
- package/scripts/hook-session-start +106 -38
- package/scripts/install.rb +91 -529
- package/scripts/lib/boot_banner.rb +28 -0
- package/scripts/lib/bridge.rb +404 -19
- package/scripts/lib/installer_core.rb +807 -0
- package/scripts/lib/intent_validator.rb +235 -0
- package/scripts/lib/qmd_hook.rb +44 -0
- package/scripts/lib/qmd_sync.rb +209 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +226 -0
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +92 -0
- package/scripts/select-update-target +93 -0
- package/scripts/spawn-preamble +121 -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 +1 -1
- package/skills/add-project-store/SKILL.md +54 -0
- package/skills/auto/SKILL.md +77 -7
- package/skills/auto/evals/evals.json +207 -0
- package/skills/auto/references/agent-architecture.md +135 -0
- package/skills/brainstorming/SKILL.md +9 -9
- package/skills/brainstorming-grill-me/SKILL.md +6 -6
- 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 +74 -36
- package/skills/creating-intent/evals/evals.json +56 -0
- package/skills/creating-intent/references/lifecycle.md +76 -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 +51 -4
- package/skills/doctor/references/gates-stuck-detection.md +38 -0
- package/skills/doctor/report.md +4 -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 +16 -7
- package/skills/linking-intents/references/zettelkasten.md +38 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/skills/managing-index/references/zettelkasten-linking.md +1 -1
- package/skills/releasing/SKILL.md +80 -23
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +2 -2
- 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 -44
- 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 +5 -5
- package/templates/agents.md +7 -7
- package/templates/outcome.md +13 -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)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# PreToolUse create gate (intent 60b): validate the PROPOSED content of a Write
|
|
6
|
+
# to an intent file before it lands. Fires when the target path is an intent file
|
|
7
|
+
# inside its own equally-named directory (store/<id>--<slug>/<id>--<slug>.md). It
|
|
8
|
+
# validates the proposed content from the hook payload (tool_input.content), NOT
|
|
9
|
+
# the on-disk file (which does not exist yet at PreToolUse), using IntentValidator
|
|
10
|
+
# for born-complete frontmatter plus the sanctioned section structure.
|
|
11
|
+
#
|
|
12
|
+
# It depends ONLY on the stdin path + content, never on the auto-bridge or
|
|
13
|
+
# CLAUDE_SESSION_ID, so it runs unconditionally (headless / background sessions).
|
|
14
|
+
# It validates ONLY the intent file, never sentinel placeholder lifecycle files
|
|
15
|
+
# (spec.md/plan.md/etc.); the path matcher excludes them.
|
|
16
|
+
#
|
|
17
|
+
# Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
|
|
18
|
+
#
|
|
19
|
+
# Reads the Claude Code PreToolUse payload as JSON on STDIN:
|
|
20
|
+
# { "tool_input": { "file_path": "...", "content": "..." } }
|
|
21
|
+
# Empty / unparseable / non-matching path => exit 0 (cannot judge, allow).
|
|
22
|
+
# Matching path but missing content => exit 2 (fail-safe: refuse to allow an
|
|
23
|
+
# unvalidated intent write).
|
|
24
|
+
|
|
25
|
+
require "json"
|
|
26
|
+
require_relative "lib/intent_validator"
|
|
27
|
+
|
|
28
|
+
raw = $stdin.read rescue nil
|
|
29
|
+
exit 0 if raw.nil? || raw.strip.empty?
|
|
30
|
+
|
|
31
|
+
payload = JSON.parse(raw) rescue nil
|
|
32
|
+
exit 0 unless payload.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
|
|
35
|
+
exit 0 if path.nil? || path.to_s.strip.empty?
|
|
36
|
+
|
|
37
|
+
# Precise path matcher: the target must be an intent file inside its own
|
|
38
|
+
# equally-named dir. Never matches sibling lifecycle files (spec.md, etc.) or
|
|
39
|
+
# unrelated store files.
|
|
40
|
+
abs = File.expand_path(path)
|
|
41
|
+
dir = File.dirname(abs)
|
|
42
|
+
is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
43
|
+
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
44
|
+
exit 0 unless is_intent_file
|
|
45
|
+
|
|
46
|
+
content = payload.dig("tool_input", "content") || payload.dig("tool_params", "content")
|
|
47
|
+
if content.nil?
|
|
48
|
+
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed content; " \
|
|
49
|
+
"refusing to allow an unvalidated intent write."
|
|
50
|
+
exit 2
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
result = IntentValidator.validate_content(content)
|
|
54
|
+
exit 0 if result[:ok]
|
|
55
|
+
|
|
56
|
+
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
|
|
57
|
+
result[:errors].each { |e| $stderr.puts " #{e}" }
|
|
58
|
+
$stderr.puts "Create intents via new-intent / plastic-creating-intent; do not hand-author them."
|
|
59
|
+
exit 2
|
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)
|
|
@@ -8,36 +8,51 @@
|
|
|
8
8
|
|
|
9
9
|
require "json"
|
|
10
10
|
require_relative "lib/bridge"
|
|
11
|
+
require_relative "lib/intent_validator"
|
|
11
12
|
|
|
12
13
|
file_path = ARGV[0]
|
|
13
14
|
exit 0 unless file_path && !file_path.empty?
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
bridge_path = nil
|
|
19
|
-
bridge_data = nil
|
|
18
|
+
file_path_abs = File.expand_path(file_path)
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
# --- Decoupled savepoint ledger (intent 34, hardened in intent 52) ---
|
|
21
|
+
# The savepoint is derived purely from the file path's intent directory, BEFORE
|
|
22
|
+
# any bridge resolution. A missing bridge must never skip the savepoint.
|
|
23
|
+
intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
|
|
24
|
+
if intent_dir_abs
|
|
25
|
+
begin
|
|
26
|
+
Bridge.append_savepoint(intent_dir_abs, file_path_abs)
|
|
27
|
+
rescue StandardError
|
|
28
|
+
# ignore — rebuildable from disk
|
|
29
|
+
end
|
|
24
30
|
end
|
|
25
31
|
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
# --- Artifact-validity backstop (intent 4a1c1) ---
|
|
33
|
+
# When the written file IS the intent file itself (the `<id>--<slug>.md` directly
|
|
34
|
+
# inside `store/<id>--<slug>/`), run IntentValidator on it. This is headless-safe:
|
|
35
|
+
# it depends only on the file path and disk, never on a bridge or session. In the
|
|
36
|
+
# PostToolUse model the write has already happened, so we cannot prevent it; the
|
|
37
|
+
# loud non-zero exit + stderr is the rejection signal. NOT for spec.md/plan.md/
|
|
38
|
+
# checklist.md/outcome.md/savepoint.md — those are validated by the stage gates.
|
|
39
|
+
if intent_dir_abs && File.basename(file_path_abs) == File.basename(Bridge.intent_file(intent_dir_abs))
|
|
40
|
+
result = IntentValidator.validate(intent_dir_abs)
|
|
41
|
+
unless result[:ok]
|
|
42
|
+
warn "PLASTIC ARTIFACT INVALID — #{File.basename(file_path_abs)} is not born complete:"
|
|
43
|
+
result[:missing].each { |field| warn " missing required field: #{field}" }
|
|
44
|
+
result[:errors].each { |error| warn " #{error}" }
|
|
45
|
+
warn "Fix the frontmatter; the intent is not valid until every required field is present and sources/chain are well-formed."
|
|
46
|
+
exit 1
|
|
36
47
|
end
|
|
37
48
|
end
|
|
38
49
|
|
|
39
|
-
#
|
|
40
|
-
|
|
50
|
+
# --- Find bridge file ---
|
|
51
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
52
|
+
|
|
53
|
+
# No bridge = no active intent = no stage enforcement. Savepoint already handled.
|
|
54
|
+
exit 0 unless bridge_data
|
|
55
|
+
session = bridge_data["session"]
|
|
41
56
|
|
|
42
57
|
# --- Resolve intent directory ---
|
|
43
58
|
|
|
@@ -49,15 +64,14 @@ dir = intent_info["dir"]
|
|
|
49
64
|
intent_dir = "#{store}/#{dir}"
|
|
50
65
|
|
|
51
66
|
# Normalize both paths for comparison
|
|
52
|
-
|
|
53
|
-
file_path_abs = File.expand_path(file_path)
|
|
67
|
+
bridge_intent_dir_abs = File.expand_path(intent_dir)
|
|
54
68
|
|
|
55
69
|
# Not inside intent dir = not our business
|
|
56
|
-
exit 0 unless file_path_abs.start_with?("#{
|
|
70
|
+
exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
|
|
57
71
|
|
|
58
72
|
# --- Determine if this is a stage-transition file ---
|
|
59
73
|
|
|
60
|
-
relative = file_path_abs.sub("#{
|
|
74
|
+
relative = file_path_abs.sub("#{bridge_intent_dir_abs}/", "")
|
|
61
75
|
basename = File.basename(file_path)
|
|
62
76
|
|
|
63
77
|
stage_files = %w[spec.md plan.md checklist.md outcome.md]
|
|
@@ -66,7 +80,7 @@ is_action_file = relative.start_with?("actions/")
|
|
|
66
80
|
|
|
67
81
|
if is_stage_file || is_action_file
|
|
68
82
|
# Run gate check
|
|
69
|
-
error = Bridge.check_gate(
|
|
83
|
+
error = Bridge.check_gate(bridge_intent_dir_abs, file_path_abs)
|
|
70
84
|
|
|
71
85
|
if error
|
|
72
86
|
# Gate violation — block the write
|
|
@@ -83,11 +97,11 @@ if is_stage_file || is_action_file
|
|
|
83
97
|
|
|
84
98
|
# Gate passed — update bridge with new stage
|
|
85
99
|
old_stage = bridge_data["build"]["stage"]
|
|
86
|
-
new_stage = Bridge.derive_stage(
|
|
87
|
-
new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(
|
|
100
|
+
new_stage = Bridge.derive_stage(bridge_intent_dir_abs)
|
|
101
|
+
new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(bridge_intent_dir_abs)
|
|
88
102
|
|
|
89
103
|
bridge_data["build"]["stage"] = new_stage
|
|
90
|
-
bridge_data["build"]["has"] = Bridge.has_files(
|
|
104
|
+
bridge_data["build"]["has"] = Bridge.has_files(bridge_intent_dir_abs)
|
|
91
105
|
bridge_data["build"]["missing"] = new_missing
|
|
92
106
|
bridge_data["build"]["gate_failures"] = 0
|
|
93
107
|
bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
|
|
@@ -102,9 +116,9 @@ if is_stage_file || is_action_file
|
|
|
102
116
|
stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
|
|
103
117
|
next_hints = {
|
|
104
118
|
"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
|
|
119
|
+
"how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
|
|
120
|
+
"exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
|
|
121
|
+
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
|
|
108
122
|
}
|
|
109
123
|
|
|
110
124
|
context_parts = ["PLASTIC"]
|
|
@@ -129,7 +143,8 @@ if is_stage_file || is_action_file
|
|
|
129
143
|
puts JSON.generate(payload)
|
|
130
144
|
exit 0
|
|
131
145
|
else
|
|
132
|
-
# Regular file in intent dir — just update last_activity
|
|
146
|
+
# Regular file in intent dir — just update last_activity. The savepoint for
|
|
147
|
+
# the What milestone was already handled above by the decoupled ledger write.
|
|
133
148
|
bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
|
|
134
149
|
Bridge.write(session, bridge_data)
|
|
135
150
|
exit 0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# qmd-first UserPromptSubmit hook (intent 66). Reads the prompt from stdin JSON,
|
|
6
|
+
# delegates the decision to QmdHook.run under a hard timeout, and prints
|
|
7
|
+
# hookSpecificOutput when there is something to inject/remind. Silent no-op (exit
|
|
8
|
+
# 0) when qmd is absent, the prompt is trivial, or anything goes wrong. ARGV[0]
|
|
9
|
+
# is the plastic_home (passed by the launcher); defaults to ~/.plastic.
|
|
10
|
+
require "json"
|
|
11
|
+
require "timeout"
|
|
12
|
+
require_relative "lib/qmd_hook"
|
|
13
|
+
|
|
14
|
+
raw = begin
|
|
15
|
+
STDIN.read
|
|
16
|
+
rescue StandardError
|
|
17
|
+
""
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
prompt = begin
|
|
21
|
+
parsed = JSON.parse(raw)
|
|
22
|
+
parsed.is_a?(Hash) ? parsed["user_prompt"].to_s : ""
|
|
23
|
+
rescue StandardError
|
|
24
|
+
""
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
|
|
28
|
+
|
|
29
|
+
context = begin
|
|
30
|
+
Timeout.timeout(2) do
|
|
31
|
+
QmdHook.run(prompt: prompt, cwd: Dir.pwd, plastic_home: plastic_home)
|
|
32
|
+
end
|
|
33
|
+
rescue Exception
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
exit 0 if context.nil? || context.strip.empty?
|
|
38
|
+
|
|
39
|
+
puts JSON.generate(
|
|
40
|
+
"hookSpecificOutput" => {
|
|
41
|
+
"hookEventName" => "UserPromptSubmit",
|
|
42
|
+
"additionalContext" => context,
|
|
43
|
+
}
|
|
44
|
+
)
|