@zalom/plastic 1.0.0-alpha.28 → 1.0.0-alpha.29
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/hooks/code-gate +3 -1
- package/hooks/gate-check +3 -1
- package/package.json +1 -1
- package/scripts/hook-auto-arm +1 -2
- package/scripts/hook-bash-gate +4 -16
- package/scripts/hook-code-gate +3 -15
- package/scripts/hook-gate-check +27 -46
- package/scripts/lib/bridge.rb +105 -12
- package/skills/auto/SKILL.md +3 -2
package/hooks/code-gate
CHANGED
|
@@ -6,5 +6,7 @@ if [ -z "$FILE_PATH" ]; then
|
|
|
6
6
|
exit 0
|
|
7
7
|
fi
|
|
8
8
|
|
|
9
|
+
SESSION_ID=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("session_id") || ""' 2>/dev/null)
|
|
10
|
+
|
|
9
11
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
-
ruby "$SCRIPT_DIR/../scripts/hook-code-gate" "$FILE_PATH"
|
|
12
|
+
ruby "$SCRIPT_DIR/../scripts/hook-code-gate" "$FILE_PATH" "$SESSION_ID"
|
package/hooks/gate-check
CHANGED
|
@@ -6,5 +6,7 @@ if [ -z "$FILE_PATH" ]; then
|
|
|
6
6
|
exit 0
|
|
7
7
|
fi
|
|
8
8
|
|
|
9
|
+
SESSION_ID=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("session_id") || ""' 2>/dev/null)
|
|
10
|
+
|
|
9
11
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
10
|
-
ruby "$SCRIPT_DIR/../scripts/hook-gate-check" "$FILE_PATH"
|
|
12
|
+
ruby "$SCRIPT_DIR/../scripts/hook-gate-check" "$FILE_PATH" "$SESSION_ID"
|
package/package.json
CHANGED
package/scripts/hook-auto-arm
CHANGED
|
@@ -26,8 +26,7 @@ if triggered
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# --- 2. Standing gate reminder when armed and pre-How ---
|
|
29
|
-
|
|
30
|
-
bridge_data = (session && !session.empty? && File.exist?(Bridge.path(session))) ? Bridge.read(session) : nil
|
|
29
|
+
bridge_data = Bridge.discover_bridge(session: ENV["CLAUDE_SESSION_ID"], cwd: Dir.pwd)
|
|
31
30
|
if bridge_data && bridge_data.dig("build", "auto") == true
|
|
32
31
|
intent = bridge_data["intent"] || {}
|
|
33
32
|
store = intent["store"]; dir = intent["dir"]
|
package/scripts/hook-bash-gate
CHANGED
|
@@ -28,22 +28,10 @@ exit 0 if command.nil? || command.to_s.strip.empty?
|
|
|
28
28
|
cwd = payload["cwd"]
|
|
29
29
|
cwd = Dir.pwd if cwd.nil? || cwd.to_s.empty?
|
|
30
30
|
|
|
31
|
-
# --- Load bridge (
|
|
32
|
-
session =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if session && !session.empty?
|
|
36
|
-
bridge_data = Bridge.read(session) if File.exist?(Bridge.path(session))
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
unless bridge_data
|
|
40
|
-
candidates = Dir.glob("/tmp/plastic-*.json").reject { |f| f.end_with?(".tmp") }
|
|
41
|
-
if candidates.any?
|
|
42
|
-
newest = candidates.max_by { |f| File.mtime(f) }
|
|
43
|
-
bridge_data = JSON.parse(File.read(newest)) rescue nil
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
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)
|
|
47
35
|
exit 0 unless bridge_data
|
|
48
36
|
|
|
49
37
|
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
|
package/scripts/hook-code-gate
CHANGED
|
@@ -14,22 +14,10 @@ require_relative "lib/bridge"
|
|
|
14
14
|
file_path = ARGV[0]
|
|
15
15
|
exit 0 unless file_path && !file_path.empty?
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
session = ENV["CLAUDE_SESSION_ID"]
|
|
19
|
-
bridge_data = nil
|
|
20
|
-
|
|
21
|
-
if session && !session.empty?
|
|
22
|
-
bridge_data = Bridge.read(session) if File.exist?(Bridge.path(session))
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
unless bridge_data
|
|
26
|
-
candidates = Dir.glob("/tmp/plastic-*.json").reject { |f| f.end_with?(".tmp") }
|
|
27
|
-
if candidates.any?
|
|
28
|
-
newest = candidates.max_by { |f| File.mtime(f) }
|
|
29
|
-
bridge_data = JSON.parse(File.read(newest)) rescue nil
|
|
30
|
-
end
|
|
31
|
-
end
|
|
17
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
|
|
32
18
|
|
|
19
|
+
# --- Load bridge (shared resolution; stdin session_id -> env -> /tmp scan) ---
|
|
20
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
33
21
|
exit 0 unless bridge_data
|
|
34
22
|
|
|
35
23
|
reason = Bridge.code_gate_decision(bridge_data, file_path)
|
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
|
|
@@ -98,14 +93,6 @@ if is_stage_file || is_action_file
|
|
|
98
93
|
|
|
99
94
|
Bridge.write(session, bridge_data)
|
|
100
95
|
|
|
101
|
-
# Cycle-step savepoint ledger (intent 34) — record this stage-file milestone.
|
|
102
|
-
# Best-effort: the ledger is derived sugar, so a failure must never block.
|
|
103
|
-
begin
|
|
104
|
-
Bridge.append_savepoint(intent_dir_abs, file_path_abs)
|
|
105
|
-
rescue StandardError
|
|
106
|
-
# ignore — rebuildable from disk
|
|
107
|
-
end
|
|
108
|
-
|
|
109
96
|
# Build transition context
|
|
110
97
|
stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
|
|
111
98
|
next_hints = {
|
|
@@ -137,14 +124,8 @@ if is_stage_file || is_action_file
|
|
|
137
124
|
puts JSON.generate(payload)
|
|
138
125
|
exit 0
|
|
139
126
|
else
|
|
140
|
-
# Regular file in intent dir — just update last_activity. The
|
|
141
|
-
#
|
|
142
|
-
# non-milestone files self-filter to a no-op (intent 34).
|
|
143
|
-
begin
|
|
144
|
-
Bridge.append_savepoint(intent_dir_abs, file_path_abs)
|
|
145
|
-
rescue StandardError
|
|
146
|
-
# ignore — rebuildable from disk
|
|
147
|
-
end
|
|
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.
|
|
148
129
|
bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
|
|
149
130
|
Bridge.write(session, bridge_data)
|
|
150
131
|
exit 0
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "yaml"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "tempfile"
|
|
8
|
+
require "digest"
|
|
8
9
|
|
|
9
10
|
module Bridge
|
|
10
11
|
STAGES = %w[what why how exec done].freeze
|
|
@@ -14,26 +15,114 @@ module Bridge
|
|
|
14
15
|
"#{intent_dir}/#{dir_name}.md"
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
# Single source for the OS temp location holding bridge files. Lets every
|
|
19
|
+
# process (arm_auto, the gate hooks) agree, and lets tests fully isolate by
|
|
20
|
+
# pointing PLASTIC_TMP at a Dir.mktmpdir. This is OS-temp-location resolution,
|
|
21
|
+
# not a logic-config injection seam.
|
|
22
|
+
def self.tmp_dir
|
|
23
|
+
t = ENV["PLASTIC_TMP"]
|
|
24
|
+
(t.nil? || t.strip.empty?) ? "/tmp" : t
|
|
19
25
|
end
|
|
20
26
|
|
|
21
|
-
def self.
|
|
22
|
-
|
|
27
|
+
def self.path(session, tmp: tmp_dir)
|
|
28
|
+
"#{tmp}/plastic-#{session}.json"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# --- Session resolution (intent 52) ----------------------------------------
|
|
32
|
+
|
|
33
|
+
def self.blank?(value)
|
|
34
|
+
value.nil? || value.to_s.strip.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Deterministic, session-id-less bridge key derived from store + intent id.
|
|
38
|
+
# Stable across processes so a session-less arm and a later session-less
|
|
39
|
+
# gate-check resolve to the same bridge file.
|
|
40
|
+
def self.derive_key(store, intent_id)
|
|
41
|
+
"auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
|
|
45
|
+
# then a derived key. Never returns nil/empty. Whitespace-only counts as empty.
|
|
46
|
+
def self.resolve_session(explicit, intent_id:, store:)
|
|
47
|
+
return explicit.to_s.strip unless blank?(explicit)
|
|
48
|
+
env = ENV["CLAUDE_SESSION_ID"]
|
|
49
|
+
return env.to_s.strip unless blank?(env)
|
|
50
|
+
derive_key(store, intent_id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Walk up from file_path; return the first ancestor that looks like an intent
|
|
54
|
+
# directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
|
|
55
|
+
# target without needing a bridge. The input is always a file inside the intent
|
|
56
|
+
# dir (never the dir itself), so the walk-up starts at its parent.
|
|
57
|
+
def self.intent_dir_for(file_path)
|
|
58
|
+
dir = File.expand_path(file_path)
|
|
59
|
+
loop do
|
|
60
|
+
parent = File.dirname(dir)
|
|
61
|
+
break if parent == dir # reached filesystem root
|
|
62
|
+
dir = parent
|
|
63
|
+
return dir if dir.match?(%r{/store/[^/]+--[^/]+\z})
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A bridge hash is usable iff it has a non-empty session and an intent Hash.
|
|
69
|
+
def self.bridge_valid?(data)
|
|
70
|
+
data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolve the active bridge. Exact-session lookup first; otherwise scan tmp:
|
|
74
|
+
# for plastic-*.json, keep only valid bridges, prefer auto-armed, then prefer
|
|
75
|
+
# the one whose intent.store matches cwd, tie-break by newest mtime.
|
|
76
|
+
def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir)
|
|
77
|
+
if !blank?(session) && File.exist?(path(session, tmp: tmp))
|
|
78
|
+
exact = read(session, tmp: tmp)
|
|
79
|
+
return exact if bridge_valid?(exact)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
|
|
83
|
+
parsed = candidates.filter_map do |f|
|
|
84
|
+
data = (JSON.parse(File.read(f)) rescue nil)
|
|
85
|
+
next unless data && bridge_valid?(data)
|
|
86
|
+
{ file: f, data: data, mtime: File.mtime(f) }
|
|
87
|
+
end
|
|
88
|
+
return nil if parsed.empty?
|
|
89
|
+
|
|
90
|
+
auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
|
|
91
|
+
pool = auto.empty? ? parsed : auto
|
|
92
|
+
|
|
93
|
+
unless blank?(cwd)
|
|
94
|
+
cwd_abs = File.expand_path(cwd)
|
|
95
|
+
matching = pool.select do |c|
|
|
96
|
+
store = c[:data].dig("intent", "store").to_s
|
|
97
|
+
next false if store.empty?
|
|
98
|
+
store_abs = File.expand_path(store)
|
|
99
|
+
cwd_abs == store_abs ||
|
|
100
|
+
cwd_abs.start_with?("#{store_abs}/") ||
|
|
101
|
+
store_abs.start_with?("#{cwd_abs}/")
|
|
102
|
+
end
|
|
103
|
+
pool = matching unless matching.empty?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def self.read(session, tmp: tmp_dir)
|
|
110
|
+
p = path(session, tmp: tmp)
|
|
23
111
|
return nil unless File.exist?(p)
|
|
24
112
|
JSON.parse(File.read(p))
|
|
25
113
|
rescue JSON::ParserError
|
|
26
114
|
nil
|
|
27
115
|
end
|
|
28
116
|
|
|
29
|
-
def self.write(session, data)
|
|
30
|
-
|
|
117
|
+
def self.write(session, data, tmp: tmp_dir)
|
|
118
|
+
raise ArgumentError, "bridge session must be present" if blank?(session)
|
|
119
|
+
p = path(session, tmp: tmp)
|
|
31
120
|
# Atomic write: tmp file + rename to prevent partial reads
|
|
32
|
-
|
|
33
|
-
File.write(
|
|
34
|
-
File.rename(
|
|
121
|
+
tmp_file = "#{p}.tmp.#{Process.pid}"
|
|
122
|
+
File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
|
|
123
|
+
File.rename(tmp_file, p)
|
|
35
124
|
rescue => e
|
|
36
|
-
File.delete(
|
|
125
|
+
File.delete(tmp_file) if tmp_file && File.exist?(tmp_file)
|
|
37
126
|
raise e
|
|
38
127
|
end
|
|
39
128
|
|
|
@@ -227,9 +316,13 @@ module Bridge
|
|
|
227
316
|
# Arm auto mode for a session+intent. Works even when no bridge exists yet
|
|
228
317
|
# (mid-session intent creation). Re-derives intent state, then sets build.auto.
|
|
229
318
|
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
|
|
230
|
-
|
|
319
|
+
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
320
|
+
if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"])
|
|
321
|
+
$stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
|
|
322
|
+
end
|
|
323
|
+
data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
231
324
|
data["build"]["auto"] = true
|
|
232
|
-
write(
|
|
325
|
+
write(key, data)
|
|
233
326
|
data
|
|
234
327
|
end
|
|
235
328
|
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -40,8 +40,9 @@ ruby -r ~/.plastic/scripts/lib/bridge -e \
|
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
Replace `<ID>`, `<STORE>` (e.g. `~/.plastic/projects/<slug>/store` or `~/.plastic/store`),
|
|
43
|
-
`<dir>` (the `ID--slug` directory), and `<name>`. If `CLAUDE_SESSION_ID` is unset,
|
|
44
|
-
|
|
43
|
+
`<dir>` (the `ID--slug` directory), and `<name>`. If `CLAUDE_SESSION_ID` is unset, `arm_auto`
|
|
44
|
+
falls back to a deterministic derived bridge key (a hash of the store and intent id), so the
|
|
45
|
+
gate still engages; arming prints a one-line notice to stderr in that case.
|
|
45
46
|
|
|
46
47
|
**Hard rule for the rest of this run:** do NOT edit project code (anything outside the
|
|
47
48
|
intent directory / `~/.plastic/`) until `plan.md` AND `checklist.md` exist for the intent.
|