@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.28",
3
+ "version": "1.0.0-alpha.29",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,8 +26,7 @@ if triggered
26
26
  end
27
27
 
28
28
  # --- 2. Standing gate reminder when armed and pre-How ---
29
- session = ENV["CLAUDE_SESSION_ID"]
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"]
@@ -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 (mirror hook-code-gate resolution) ---
32
- session = ENV["CLAUDE_SESSION_ID"]
33
- bridge_data = nil
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)
@@ -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
- # --- Load bridge (mirror hook-gate-check resolution) ---
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)
@@ -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
- # --- Find bridge file ---
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
- if session && !session.empty?
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
- # Fallback: scan /tmp for most recent plastic bridge file
27
- unless bridge_data
28
- candidates = Dir.glob("/tmp/plastic-*.json").reject { |f| f.end_with?(".tmp") }
29
- if candidates.any?
30
- bridge_path = candidates.max_by { |f| File.mtime(f) }
31
- raw = JSON.parse(File.read(bridge_path)) rescue nil
32
- if raw
33
- session = raw["session"]
34
- bridge_data = raw
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
- # No bridge = no active intent = no enforcement
40
- exit 0 unless bridge_data && session
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
- intent_dir_abs = File.expand_path(intent_dir)
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?("#{intent_dir_abs}/")
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("#{intent_dir_abs}/", "")
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(intent_dir_abs, file_path_abs)
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(intent_dir_abs)
87
- new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(intent_dir_abs)
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(intent_dir_abs)
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 intent file
141
- # (What milestone) lands here, so still offer it to the savepoint ledger;
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
@@ -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
- def self.path(session)
18
- "/tmp/plastic-#{session}.json"
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.read(session)
22
- p = path(session)
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
- p = path(session)
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
- tmp = "#{p}.tmp.#{Process.pid}"
33
- File.write(tmp, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
34
- File.rename(tmp, p)
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(tmp) if tmp && File.exist?(tmp)
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
- data = derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
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(session, data)
325
+ write(key, data)
233
326
  data
234
327
  end
235
328
 
@@ -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, arming
44
- is best-effort proceed regardless; the gate simply won't engage.
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.