@zalom/plastic 1.0.0-beta.24 → 1.0.0-beta.26
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 +101 -31
- package/agents/plastic-intent-curator.md +4 -1
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +3 -3
- package/hooks/lock-gate +10 -1
- package/package.json +1 -1
- package/scripts/doctor.rb +225 -3
- package/scripts/hook-bash-gate +21 -3
- package/scripts/hook-create-gate +43 -7
- package/scripts/hook-gate-check +10 -0
- package/scripts/hook-lock-gate +14 -3
- package/scripts/hook-retrieval-gate +29 -17
- package/scripts/lib/bridge.rb +231 -73
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/installer_core.rb +8 -62
- package/scripts/lib/lock.rb +193 -0
- package/scripts/lib/power_tools.rb +11 -12
- package/scripts/lib/worktree.rb +21 -46
- package/scripts/plastic-lock +120 -0
- package/skills/auto/SKILL.md +40 -11
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/intent-curator/SKILL.md +4 -3
- package/skills/intent-starting/SKILL.md +16 -3
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +5 -1
- package/templates/outcome.md +3 -0
package/scripts/hook-lock-gate
CHANGED
|
@@ -10,15 +10,26 @@
|
|
|
10
10
|
# reads never reach here.
|
|
11
11
|
require "json"
|
|
12
12
|
require_relative "lib/bridge"
|
|
13
|
+
require_relative "lib/lock"
|
|
13
14
|
|
|
14
15
|
begin
|
|
15
16
|
file_path = ARGV[0]
|
|
16
17
|
exit 0 unless file_path && !file_path.empty?
|
|
17
18
|
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
18
19
|
|
|
19
|
-
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no
|
|
20
|
-
reason = Bridge.lock_gate_decision(bridge_data, file_path)
|
|
21
|
-
|
|
20
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
|
|
21
|
+
reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session)
|
|
22
|
+
unless reason
|
|
23
|
+
# Allow path: refresh the lease for the session that holds this target's
|
|
24
|
+
# lock (owner or delegate). Best-effort, never blocks.
|
|
25
|
+
begin
|
|
26
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
27
|
+
Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
|
|
28
|
+
rescue StandardError
|
|
29
|
+
# ignore
|
|
30
|
+
end
|
|
31
|
+
exit 0
|
|
32
|
+
end
|
|
22
33
|
|
|
23
34
|
print JSON.generate(
|
|
24
35
|
"hookSpecificOutput" => {
|
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
# PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a
|
|
6
|
-
# Reads the tool call (JSON on stdin: tool_name +
|
|
7
|
-
# capabilities (QMD detect + freshness), delegates the
|
|
8
|
-
# and
|
|
9
|
-
#
|
|
5
|
+
# PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a;
|
|
6
|
+
# ADVISORY since intent 108, D8). Reads the tool call (JSON on stdin: tool_name +
|
|
7
|
+
# tool_input), computes capabilities (QMD detect + freshness), and delegates the
|
|
8
|
+
# decision to RetrievalGate. It NEVER blocks: reads and searches always run
|
|
9
|
+
# (exit 0). A store content search that QMD could serve better gets a QMD hint
|
|
10
|
+
# via PreToolUse additionalContext on stdout. Hard gates guard writes, locks,
|
|
11
|
+
# and structure; never reads.
|
|
10
12
|
# Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
|
|
11
13
|
# STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
|
|
12
14
|
# Binds subagents (PreToolUse hooks apply to subagent tool calls too).
|
|
13
15
|
#
|
|
14
|
-
# Only CONTENT SEARCH over store markdown
|
|
15
|
-
#
|
|
16
|
-
#
|
|
16
|
+
# Only CONTENT SEARCH over store markdown gets the hint; reads and structural
|
|
17
|
+
# ops stay silent. Code navigation is a soft prompt recommendation
|
|
18
|
+
# (UserPromptSubmit power-tools), not handled here.
|
|
17
19
|
#
|
|
18
20
|
# Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
|
|
19
21
|
# scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
|
|
@@ -29,13 +31,13 @@ module RetrievalGateHook
|
|
|
29
31
|
module_function
|
|
30
32
|
|
|
31
33
|
# Pure-ish core: capabilities and reindex are injected so this is unit-testable
|
|
32
|
-
# with no real qmd. Returns [exit_code, stderr_string].
|
|
34
|
+
# with no real qmd. Returns [exit_code, stderr_string, stdout_json].
|
|
33
35
|
# stdin: raw PreToolUse JSON
|
|
34
36
|
# capabilities: { qmd:, qmd_fresh: }
|
|
35
37
|
# reindex: callable fired on the STALE path
|
|
36
38
|
def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
|
|
37
39
|
payload = parse(stdin)
|
|
38
|
-
return [0, nil] unless payload
|
|
40
|
+
return [0, nil, nil] unless payload
|
|
39
41
|
|
|
40
42
|
tool_name = payload["tool_name"].to_s
|
|
41
43
|
tool_input = payload["tool_input"]
|
|
@@ -49,14 +51,23 @@ module RetrievalGateHook
|
|
|
49
51
|
) { |_sig| bypassed = true }
|
|
50
52
|
|
|
51
53
|
if reason
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
# Advisory (intent 108, D8): reads and searches are never denied. The
|
|
55
|
+
# would-block reason becomes a hint the agent sees alongside the result.
|
|
56
|
+
hint = JSON.generate(
|
|
57
|
+
"hookSpecificOutput" => {
|
|
58
|
+
"hookEventName" => "PreToolUse",
|
|
59
|
+
"additionalContext" =>
|
|
60
|
+
"PLASTIC advisory: #{reason} (this search ran; the hint is not a block)"
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
[0, nil, hint]
|
|
55
64
|
else
|
|
56
|
-
|
|
65
|
+
# The `# qmd-ok` bypass token is still accepted (bypassed is set), but
|
|
66
|
+
# with nothing to bypass it no longer announces itself.
|
|
67
|
+
[0, nil, nil]
|
|
57
68
|
end
|
|
58
69
|
rescue StandardError
|
|
59
|
-
[0, nil] # fail-open
|
|
70
|
+
[0, nil, nil] # fail-open
|
|
60
71
|
end
|
|
61
72
|
|
|
62
73
|
def parse(raw)
|
|
@@ -120,7 +131,7 @@ if $PROGRAM_NAME == __FILE__
|
|
|
120
131
|
plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
|
|
121
132
|
cwd = Dir.pwd
|
|
122
133
|
|
|
123
|
-
code, err = begin
|
|
134
|
+
code, err, out = begin
|
|
124
135
|
caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
|
|
125
136
|
RetrievalGateHook.run(
|
|
126
137
|
stdin: raw, plastic_home: plastic_home, cwd: cwd,
|
|
@@ -128,9 +139,10 @@ if $PROGRAM_NAME == __FILE__
|
|
|
128
139
|
reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
|
|
129
140
|
)
|
|
130
141
|
rescue StandardError
|
|
131
|
-
[0, nil] # fail-open at the outermost boundary too
|
|
142
|
+
[0, nil, nil] # fail-open at the outermost boundary too
|
|
132
143
|
end
|
|
133
144
|
|
|
134
145
|
$stderr.puts(err) if err && !err.empty?
|
|
146
|
+
print out if out && !out.empty?
|
|
135
147
|
exit code
|
|
136
148
|
end
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "tempfile"
|
|
|
8
8
|
require "digest"
|
|
9
9
|
require "socket"
|
|
10
10
|
require_relative "worktree"
|
|
11
|
+
require_relative "lock"
|
|
11
12
|
|
|
12
13
|
module Bridge
|
|
13
14
|
STAGES = %w[what why how exec done].freeze
|
|
@@ -54,6 +55,31 @@ module Bridge
|
|
|
54
55
|
value.nil? || value.to_s.strip.empty?
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
# Raised by arm when the delivery lock cannot be acquired (held elsewhere,
|
|
59
|
+
# stale, excluded, or corrupt). The message names the resolving command.
|
|
60
|
+
class LockHeldError < StandardError; end
|
|
61
|
+
|
|
62
|
+
# The absolute intent dir a bridge points at, or nil.
|
|
63
|
+
def self.bridge_intent_dir(bridge_data)
|
|
64
|
+
return nil unless bridge_data.is_a?(Hash)
|
|
65
|
+
info = bridge_data["intent"] || {}
|
|
66
|
+
store = info["store"]
|
|
67
|
+
dir = info["dir"]
|
|
68
|
+
(store && dir) ? File.expand_path("#{store}/#{dir}") : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Bridge-cache copy of the durable lock file's fields (D2: the bridge is a
|
|
72
|
+
# CACHE; the file is the truth). Never carries a pid.
|
|
73
|
+
def self.lock_cache(lock_data)
|
|
74
|
+
{
|
|
75
|
+
"owner_session" => lock_data["owner_session"],
|
|
76
|
+
"acquired_at" => lock_data["acquired_at"],
|
|
77
|
+
"host" => lock_data["host"],
|
|
78
|
+
"type" => lock_data["type"],
|
|
79
|
+
"delegates" => Array(lock_data["delegates"]),
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
57
83
|
# Deterministic, session-id-less bridge key derived from store + intent id.
|
|
58
84
|
# Stable across processes so a session-less arm and a later session-less
|
|
59
85
|
# gate-check resolve to the same bridge file.
|
|
@@ -206,6 +232,13 @@ module Bridge
|
|
|
206
232
|
id = data.dig("intent", "id")
|
|
207
233
|
store = data.dig("intent", "store")
|
|
208
234
|
keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
|
|
235
|
+
# Never purge a bridge whose intent still holds a delivery lock
|
|
236
|
+
# (intent 108, D6): the End tail clears the lock BEFORE the bridge
|
|
237
|
+
# becomes purge-eligible, so a held lock means the tail is not done.
|
|
238
|
+
unless keep
|
|
239
|
+
dir = bridge_intent_dir(data)
|
|
240
|
+
keep = !dir.nil? && File.exist?(Lock.path(dir))
|
|
241
|
+
end
|
|
209
242
|
end
|
|
210
243
|
next if keep
|
|
211
244
|
File.delete(f)
|
|
@@ -476,7 +509,7 @@ module Bridge
|
|
|
476
509
|
lines.length
|
|
477
510
|
end
|
|
478
511
|
|
|
479
|
-
def self.derive(session, intent_id:, intent_dir:, store:, name:)
|
|
512
|
+
def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
|
|
480
513
|
stage = derive_stage(intent_dir)
|
|
481
514
|
has = has_files(intent_dir)
|
|
482
515
|
missing = missing_for_stage(stage, intent_dir) - has
|
|
@@ -516,17 +549,18 @@ module Bridge
|
|
|
516
549
|
"store_branch" => nil,
|
|
517
550
|
"provisioned" => false
|
|
518
551
|
},
|
|
519
|
-
# Delivery
|
|
520
|
-
#
|
|
552
|
+
# Delivery-lock CACHE block (intent 108, D2). The durable truth is the
|
|
553
|
+
# delivery.lock file in the intent dir; arm fills this cache from it.
|
|
521
554
|
"lock" => {
|
|
522
555
|
"owner_session" => nil,
|
|
523
|
-
"pid" => nil,
|
|
524
556
|
"acquired_at" => nil,
|
|
525
|
-
"host" => nil
|
|
557
|
+
"host" => nil,
|
|
558
|
+
"type" => nil,
|
|
559
|
+
"delegates" => []
|
|
526
560
|
}
|
|
527
561
|
}
|
|
528
562
|
|
|
529
|
-
write(session, data)
|
|
563
|
+
write(session, data, tmp: tmp)
|
|
530
564
|
data
|
|
531
565
|
end
|
|
532
566
|
|
|
@@ -599,14 +633,27 @@ module Bridge
|
|
|
599
633
|
data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
600
634
|
data["build"]["auto"] = auto
|
|
601
635
|
|
|
602
|
-
# Acquire the delivery lock:
|
|
603
|
-
# intent
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
"
|
|
609
|
-
|
|
636
|
+
# Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
|
|
637
|
+
# intent dir. The bridge lock block is a cache of the file.
|
|
638
|
+
intent_dir_abs = File.expand_path(intent_dir)
|
|
639
|
+
status, lock_data = Lock.acquire(intent_dir_abs, session: key)
|
|
640
|
+
case status
|
|
641
|
+
when :acquired, :owned
|
|
642
|
+
data["lock"] = lock_cache(lock_data)
|
|
643
|
+
when :held
|
|
644
|
+
raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
|
|
645
|
+
"session #{lock_data && lock_data['owner_session']}; run /plastic-lock status"
|
|
646
|
+
when :stale
|
|
647
|
+
raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
|
|
648
|
+
"(owner #{lock_data && lock_data['owner_session']}); run /plastic-lock " \
|
|
649
|
+
"reclaim to take it over with an audit"
|
|
650
|
+
when :excluded
|
|
651
|
+
raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
|
|
652
|
+
"intent #{intent_id}; run /plastic-lock status"
|
|
653
|
+
when :corrupt
|
|
654
|
+
raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
|
|
655
|
+
"run /plastic-lock fix"
|
|
656
|
+
end
|
|
610
657
|
|
|
611
658
|
# Provision the per-intent worktrees (mandatory code worktree for project
|
|
612
659
|
# intents; fail-open for non-git / global-only). Never let a provision error
|
|
@@ -636,7 +683,11 @@ module Bridge
|
|
|
636
683
|
arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: false)
|
|
637
684
|
end
|
|
638
685
|
|
|
639
|
-
# Disarm
|
|
686
|
+
# Disarm. No-op if no bridge exists for the session. End-tail order (D6):
|
|
687
|
+
# worktrees are merged/removed FIRST (the verify step is the caller's,
|
|
688
|
+
# before disarm), then the delivery lock is cleared, and only then does the
|
|
689
|
+
# bridge become purge-eligible. purge_done_bridges enforces the same order
|
|
690
|
+
# defensively by skipping any bridge whose intent still holds a lock.
|
|
640
691
|
def self.disarm_auto(session)
|
|
641
692
|
data = read(session)
|
|
642
693
|
return nil unless data
|
|
@@ -652,11 +703,73 @@ module Bridge
|
|
|
652
703
|
$stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
|
|
653
704
|
end
|
|
654
705
|
|
|
706
|
+
dir = bridge_intent_dir(data)
|
|
707
|
+
if dir
|
|
708
|
+
owner = data.dig("lock", "owner_session")
|
|
709
|
+
owner = session if blank?(owner)
|
|
710
|
+
Lock.release(dir, session: owner)
|
|
711
|
+
end
|
|
712
|
+
data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
|
|
713
|
+
"host" => nil, "type" => nil, "delegates" => [] }
|
|
714
|
+
|
|
655
715
|
write(session, data)
|
|
656
716
|
purge_done_bridges(session: session)
|
|
657
717
|
data
|
|
658
718
|
end
|
|
659
719
|
|
|
720
|
+
# One deterministic, idempotent repair (intent 108, D5): diagnose, remove
|
|
721
|
+
# faulty own-side state, rebuild the durable lock AND the bridge cache from
|
|
722
|
+
# disk truth for the current session. Legacy /tmp-only pid locks are
|
|
723
|
+
# migrated here: the delivery.lock file is created and the cache rebuilt
|
|
724
|
+
# without a pid. NEVER touches a fresh foreign lock (reports "held"); a
|
|
725
|
+
# stale foreign lock reports "stale" and is taken only by the explicit
|
|
726
|
+
# reclaim verb (Lock.takeover). Two entry points call this: the
|
|
727
|
+
# plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
|
|
728
|
+
def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
|
|
729
|
+
now: Time.now, tmp: tmp_dir)
|
|
730
|
+
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
731
|
+
dir = File.expand_path(intent_dir)
|
|
732
|
+
actions = []
|
|
733
|
+
|
|
734
|
+
if Lock.corrupt?(dir)
|
|
735
|
+
File.delete(Lock.path(dir))
|
|
736
|
+
actions << "removed corrupt delivery.lock"
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
lock = Lock.read(dir)
|
|
740
|
+
if lock && !Lock.authorized?(lock, key)
|
|
741
|
+
if Lock.fresh?(dir, now: now)
|
|
742
|
+
return { "status" => "held", "owner" => lock["owner_session"],
|
|
743
|
+
"actions" => actions, "session" => key }
|
|
744
|
+
end
|
|
745
|
+
return { "status" => "stale", "owner" => lock["owner_session"],
|
|
746
|
+
"actions" => actions, "session" => key,
|
|
747
|
+
"hint" => "run /plastic-lock reclaim to take over with an audit" }
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
if lock
|
|
751
|
+
Lock.heartbeat(dir, session: key, now: now)
|
|
752
|
+
lock_data = Lock.read(dir)
|
|
753
|
+
role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
|
|
754
|
+
actions << "lock kept (#{role})"
|
|
755
|
+
else
|
|
756
|
+
status, lock_data = Lock.acquire(dir, session: key, now: now)
|
|
757
|
+
actions << "lock #{status}"
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
previous = read(key, tmp: tmp)
|
|
761
|
+
auto = !!(previous && previous.dig("build", "auto"))
|
|
762
|
+
data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
|
|
763
|
+
name: name, tmp: tmp)
|
|
764
|
+
data["build"]["auto"] = auto
|
|
765
|
+
data["lock"] = lock_cache(lock_data)
|
|
766
|
+
write(key, data, tmp: tmp)
|
|
767
|
+
actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
|
|
768
|
+
|
|
769
|
+
{ "status" => "repaired", "actions" => actions,
|
|
770
|
+
"lock" => lock_data, "session" => key }
|
|
771
|
+
end
|
|
772
|
+
|
|
660
773
|
# Decide whether a code edit should be blocked while auto mode is armed.
|
|
661
774
|
# Returns a reason string to BLOCK, or nil to ALLOW.
|
|
662
775
|
#
|
|
@@ -693,58 +806,58 @@ module Bridge
|
|
|
693
806
|
|
|
694
807
|
# --- Fail-closed lock gate (intent 96) -------------------------------------
|
|
695
808
|
|
|
696
|
-
# Returns a reason String to BLOCK, or nil to ALLOW.
|
|
697
|
-
#
|
|
698
|
-
#
|
|
699
|
-
#
|
|
700
|
-
#
|
|
701
|
-
#
|
|
702
|
-
#
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
# Project code is NOT gated here (D2) - it is isolated per-intent by worktree +
|
|
706
|
-
# branch, not by a write-time lock.
|
|
707
|
-
def self.lock_gate_decision(bridge_data, file_path)
|
|
809
|
+
# Returns a reason String to BLOCK, or nil to ALLOW. Decides from the
|
|
810
|
+
# durable delivery.lock in the TARGET intent dir (D2): the bridge argument
|
|
811
|
+
# only supplies a fallback session id, so a missing or disagreeing bridge
|
|
812
|
+
# never changes the verdict. Every deny names the exact resolving command
|
|
813
|
+
# (D5). ALLOW: non-intent paths, not-yet-active intents, and any session the
|
|
814
|
+
# target's lock names as owner or delegate (even when stale: a stale lock is
|
|
815
|
+
# still its owner's until an explicit takeover).
|
|
816
|
+
def self.lock_gate_decision(bridge_data, file_path, session: nil,
|
|
817
|
+
ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
708
818
|
return nil if blank?(file_path)
|
|
709
819
|
|
|
710
|
-
# Resolve the TARGET intent first so allow-by-lock is scoped to that intent.
|
|
711
820
|
target_dir = intent_dir_for(file_path)
|
|
712
|
-
return nil unless target_dir
|
|
713
|
-
id = intent_id_from_dir(target_dir)
|
|
714
|
-
store = File.dirname(target_dir)
|
|
715
|
-
return nil unless id && intent_active?(id, store: store)
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
# pid, so fall back to "held" (a remote live owner should still block).
|
|
730
|
-
def self.holds_live_lock?(bridge_data)
|
|
731
|
-
return false unless bridge_data.is_a?(Hash)
|
|
732
|
-
lock = bridge_data["lock"] || {}
|
|
733
|
-
return false if blank?(lock["owner_session"])
|
|
734
|
-
pid = lock["pid"]
|
|
735
|
-
host = lock["host"]
|
|
736
|
-
this_host = (Socket.gethostname rescue nil)
|
|
737
|
-
if host && this_host && host == this_host && pid.is_a?(Integer)
|
|
738
|
-
begin
|
|
739
|
-
Process.kill(0, pid) # raises Errno::ESRCH if dead
|
|
740
|
-
return true
|
|
741
|
-
rescue Errno::ESRCH
|
|
742
|
-
return false # dead owner -> not a held lock -> gate blocks (re-lock)
|
|
743
|
-
rescue Errno::EPERM
|
|
744
|
-
return true # exists, not ours to signal -> live
|
|
821
|
+
return nil unless target_dir
|
|
822
|
+
id = intent_id_from_dir(target_dir)
|
|
823
|
+
store = File.dirname(target_dir)
|
|
824
|
+
return nil unless id && intent_active?(id, store: store)
|
|
825
|
+
|
|
826
|
+
sess = session
|
|
827
|
+
sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
|
|
828
|
+
|
|
829
|
+
lock = Lock.read(target_dir)
|
|
830
|
+
if lock
|
|
831
|
+
return nil if Lock.authorized?(lock, sess)
|
|
832
|
+
if Lock.fresh?(target_dir, ttl: ttl, now: now)
|
|
833
|
+
return "intent #{id} delivery lock is held by session " \
|
|
834
|
+
"#{lock['owner_session']}. Back off; if you are the owner's " \
|
|
835
|
+
"subagent, the owner must run: plastic-lock delegate " \
|
|
836
|
+
"--intent-dir #{target_dir} --session <your-session-id>. " \
|
|
837
|
+
"Inspect with /plastic-lock status"
|
|
745
838
|
end
|
|
839
|
+
return "intent #{id} has a stale delivery lock (owner " \
|
|
840
|
+
"#{lock['owner_session']}); run /plastic-lock reclaim to take " \
|
|
841
|
+
"it over, or /plastic-lock fix"
|
|
842
|
+
end
|
|
843
|
+
if Lock.corrupt?(target_dir)
|
|
844
|
+
return "delivery.lock for intent #{id} is unreadable; run /plastic-lock fix"
|
|
746
845
|
end
|
|
747
|
-
|
|
846
|
+
"no delivery lock held for intent #{id}; run /plastic-intent-starting " \
|
|
847
|
+
"to lock and begin"
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
# A session holds an intent's lock iff the durable delivery.lock in the
|
|
851
|
+
# intent dir names it as owner or delegate (D1/D4). The bridge is only a
|
|
852
|
+
# cache: the lock FILE decides, so a wiped /tmp or a clobbered bridge never
|
|
853
|
+
# strands the owner. No pid is consulted anywhere.
|
|
854
|
+
def self.holds_live_lock?(bridge_data, session: nil)
|
|
855
|
+
sess = session
|
|
856
|
+
sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
|
|
857
|
+
return false if blank?(sess)
|
|
858
|
+
dir = bridge_intent_dir(bridge_data)
|
|
859
|
+
return false unless dir
|
|
860
|
+
Lock.holds?(dir, session: sess)
|
|
748
861
|
end
|
|
749
862
|
|
|
750
863
|
# "<id>" from a ".../store/<id>--<slug>" dir, else nil.
|
|
@@ -782,18 +895,24 @@ module Bridge
|
|
|
782
895
|
under_own_intent = intent_dir_abs &&
|
|
783
896
|
(file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
|
|
784
897
|
|
|
785
|
-
# Rule 1:
|
|
898
|
+
# Rule 1 (fixed in intent 108, D7): confinement applies ONLY to paths
|
|
899
|
+
# inside the project repo. The repo root is derived from the provisioned
|
|
900
|
+
# code worktree path, which is <repo>/.claude/worktrees/{id}--{slug} by
|
|
901
|
+
# construction, so no git call is needed. Paths outside the repo (agent
|
|
902
|
+
# memory dirs, scratch files, unrelated checkouts) are not this gate's
|
|
903
|
+
# business; the 2026-07-02 memory-dir denial came from treating everything
|
|
904
|
+
# outside the worktree as the shared checkout.
|
|
786
905
|
worktree = bridge_data["worktree"] || {}
|
|
787
906
|
if worktree["provisioned"] == true
|
|
788
907
|
code = worktree["code"].to_s
|
|
789
|
-
|
|
790
|
-
is_project_code = !under_plastic && !under_own_intent
|
|
791
|
-
if is_project_code && !blank?(code)
|
|
908
|
+
if !blank?(code) && !under_plastic && !under_own_intent
|
|
792
909
|
code_abs = File.expand_path(code)
|
|
910
|
+
repo_abs = File.expand_path(File.join(code_abs, "..", "..", ".."))
|
|
911
|
+
inside_repo = file_abs == repo_abs || file_abs.start_with?("#{repo_abs}/")
|
|
793
912
|
inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
|
|
794
|
-
|
|
913
|
+
if inside_repo && !inside_code
|
|
795
914
|
id = intent_info["id"]
|
|
796
|
-
return "intent #{id} is isolated to its worktree
|
|
915
|
+
return "intent #{id} is isolated to its worktree - edit project code " \
|
|
797
916
|
"inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
|
|
798
917
|
end
|
|
799
918
|
end
|
|
@@ -877,6 +996,7 @@ module Bridge
|
|
|
877
996
|
# Split on command separators for per-segment utility parsing.
|
|
878
997
|
command.split(/[;\n]|&&|\|\||\|/).each do |segment|
|
|
879
998
|
targets.concat(bash_utility_targets(segment))
|
|
999
|
+
targets.concat(interpreter_write_targets(segment))
|
|
880
1000
|
end
|
|
881
1001
|
targets.uniq
|
|
882
1002
|
end
|
|
@@ -952,18 +1072,56 @@ module Bridge
|
|
|
952
1072
|
path == "/dev/null" || path.start_with?("/dev/")
|
|
953
1073
|
end
|
|
954
1074
|
|
|
955
|
-
#
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1075
|
+
# --- Interpreter inline-code writes (intent 108, D7) ---
|
|
1076
|
+
|
|
1077
|
+
INTERPRETER_RE = /\b(ruby|python3?|perl|node)\b(?:\s+\S+)*?\s+(-e|-c)\s+(.+)\z/m.freeze
|
|
1078
|
+
|
|
1079
|
+
# Write verbs that mark inline code as file-mutating. Conservative: reads
|
|
1080
|
+
# (File.read, puts) never match.
|
|
1081
|
+
WRITE_VERB_RE = /File\.(?:write|binwrite|open)|IO\.write|FileUtils\.|
|
|
1082
|
+
open\s*\([^)]*["'][wa]|writeFileSync|fs\.write/x.freeze
|
|
1083
|
+
|
|
1084
|
+
# Quoted absolute or ~/ paths inside the inline code.
|
|
1085
|
+
INLINE_PATH_RE = %r{["']((?:/|~/)[^"']+)["']}.freeze
|
|
1086
|
+
|
|
1087
|
+
# Paths an interpreter one-liner writes. Flagged only when the inline code
|
|
1088
|
+
# has BOTH a write verb AND a quoted absolute path; everything else (reads,
|
|
1089
|
+
# ARGV-driven paths, the sanctioned arm one-liners) yields no targets.
|
|
1090
|
+
def self.interpreter_write_targets(segment)
|
|
1091
|
+
m = INTERPRETER_RE.match(segment.to_s)
|
|
1092
|
+
return [] unless m
|
|
1093
|
+
util, flag, code = m[1], m[2], m[3]
|
|
1094
|
+
expected = { "ruby" => "-e", "python" => "-c", "python3" => "-c",
|
|
1095
|
+
"perl" => "-e", "node" => "-e" }[util]
|
|
1096
|
+
return [] unless flag == expected
|
|
1097
|
+
return [] unless WRITE_VERB_RE.match?(code)
|
|
1098
|
+
code.scan(INLINE_PATH_RE).flatten.map { |p| File.expand_path(p) }
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# Decide whether a Bash command should be blocked. Every write target runs
|
|
1102
|
+
# through the SAME policy stack as a direct tool write: the auto-mode code
|
|
1103
|
+
# gate AND the delivery-lock gate (intent 108, D7), so bash and interpreter
|
|
1104
|
+
# writes cannot bypass the lock. Returns the first block reason, or nil.
|
|
1105
|
+
def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home, session: nil)
|
|
959
1106
|
bash_write_targets(command).each do |target|
|
|
960
1107
|
abs = File.absolute_path?(target) ? target : File.join(cwd, target)
|
|
961
|
-
|
|
1108
|
+
abs = File.expand_path(abs)
|
|
1109
|
+
reason = code_gate_decision(bridge_data, abs, home: home) ||
|
|
1110
|
+
lock_gate_decision(bridge_data, abs, session: session)
|
|
962
1111
|
return reason if reason
|
|
963
1112
|
end
|
|
964
1113
|
nil
|
|
965
1114
|
end
|
|
966
1115
|
|
|
1116
|
+
# A TRAILING `# plastic-ok` shell comment: the auditable escape for
|
|
1117
|
+
# sanctioned bash/interpreter writes (mirrors the retrieval gate's
|
|
1118
|
+
# `# qmd-ok`). The hook logs every use to ~/.plastic/.cache/gate-escapes.log.
|
|
1119
|
+
PLASTIC_OK_RE = /(?:\A|\s)#\s*plastic-ok\s*\z/.freeze
|
|
1120
|
+
|
|
1121
|
+
def self.bash_escape?(command)
|
|
1122
|
+
PLASTIC_OK_RE.match?(command.to_s.chomp)
|
|
1123
|
+
end
|
|
1124
|
+
|
|
967
1125
|
def self.deep_merge(base, overlay)
|
|
968
1126
|
result = base.dup
|
|
969
1127
|
overlay.each do |key, value|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# HookRegistry: THE single source of truth for Plastic's hook registration
|
|
5
|
+
# (intent 108, D7). Three consumers, none of which may hand-roll matchers:
|
|
6
|
+
# - InstallerCore#merge_claude_hooks builds settings.json entries from it
|
|
7
|
+
# - hooks/hooks.json (legacy plugin surface) is pinned to it by test
|
|
8
|
+
# - doctor's hooks_match_registry check compares live settings against it
|
|
9
|
+
# Change registrations HERE and only here.
|
|
10
|
+
module HookRegistry
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# MCP tools that MUTATE files. Every write/lock/create gate must match them,
|
|
14
|
+
# or symbolic edits bypass the whole gate layer (the universal MCP-edit
|
|
15
|
+
# bypass found in 108's gate inventory).
|
|
16
|
+
SERENA_EDIT_TOOLS = %w[
|
|
17
|
+
mcp__serena__replace_content
|
|
18
|
+
mcp__serena__replace_symbol_body
|
|
19
|
+
mcp__serena__insert_after_symbol
|
|
20
|
+
mcp__serena__insert_before_symbol
|
|
21
|
+
mcp__serena__safe_delete_symbol
|
|
22
|
+
mcp__serena__rename_symbol
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
WRITE_MATCHER = (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).join("|")
|
|
26
|
+
CREATE_MATCHER = (%w[Write Edit] + SERENA_EDIT_TOOLS).join("|")
|
|
27
|
+
|
|
28
|
+
# event => ordered list of { "matcher" =>, "hooks" => [{ "name" =>, "status" => }] }
|
|
29
|
+
# The name is the hooks/<name> launcher; the flat install ships it as
|
|
30
|
+
# ~/.claude/hooks/plastic-<name>.
|
|
31
|
+
def events
|
|
32
|
+
{
|
|
33
|
+
"SessionStart" => [
|
|
34
|
+
{ "matcher" => "", "hooks" => [
|
|
35
|
+
{ "name" => "session-start", "status" => "Loading Plastic context..." },
|
|
36
|
+
{ "name" => "check-update", "status" => "" },
|
|
37
|
+
] },
|
|
38
|
+
],
|
|
39
|
+
"PreCompact" => [
|
|
40
|
+
{ "matcher" => "", "hooks" => [
|
|
41
|
+
{ "name" => "savepoint", "status" => "Saving Plastic intent state..." },
|
|
42
|
+
] },
|
|
43
|
+
],
|
|
44
|
+
"PreToolUse" => [
|
|
45
|
+
{ "matcher" => WRITE_MATCHER, "hooks" => [
|
|
46
|
+
{ "name" => "code-gate", "status" => "Checking lifecycle gate..." },
|
|
47
|
+
{ "name" => "lock-gate", "status" => "Checking lock gate..." },
|
|
48
|
+
] },
|
|
49
|
+
{ "matcher" => "Write|Edit", "hooks" => [
|
|
50
|
+
{ "name" => "savepoint-pre", "status" => "Recording stage start..." },
|
|
51
|
+
] },
|
|
52
|
+
{ "matcher" => CREATE_MATCHER, "hooks" => [
|
|
53
|
+
{ "name" => "create-gate", "status" => "Checking create gate..." },
|
|
54
|
+
] },
|
|
55
|
+
{ "matcher" => "Bash", "hooks" => [
|
|
56
|
+
{ "name" => "bash-gate", "status" => "Checking lifecycle gate..." },
|
|
57
|
+
] },
|
|
58
|
+
{ "matcher" => "Bash|Read|Grep|Glob", "hooks" => [
|
|
59
|
+
{ "name" => "retrieval-gate", "status" => "Checking retrieval gate..." },
|
|
60
|
+
] },
|
|
61
|
+
],
|
|
62
|
+
"PostToolUse" => [
|
|
63
|
+
{ "matcher" => "Write|Edit", "hooks" => [
|
|
64
|
+
{ "name" => "gate-check", "status" => "Checking lifecycle gates..." },
|
|
65
|
+
] },
|
|
66
|
+
],
|
|
67
|
+
"UserPromptSubmit" => [
|
|
68
|
+
{ "matcher" => "", "hooks" => [
|
|
69
|
+
{ "name" => "continue", "status" => "Checking for continue..." },
|
|
70
|
+
{ "name" => "future-intent-check", "status" => "Checking future intents..." },
|
|
71
|
+
{ "name" => "auto-arm", "status" => "Checking auto mode..." },
|
|
72
|
+
{ "name" => "qmd-search", "status" => "Searching QMD..." },
|
|
73
|
+
] },
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The settings.json shape merge_claude_hooks expects: single-group events map
|
|
79
|
+
# to a Hash, multi-group events to an Array (the merge loop handles both).
|
|
80
|
+
def claude_settings_hooks(hook_dir:)
|
|
81
|
+
events.each_with_object({}) do |(event, groups), out|
|
|
82
|
+
mapped = groups.map do |g|
|
|
83
|
+
{
|
|
84
|
+
"matcher" => g["matcher"],
|
|
85
|
+
"hooks" => g["hooks"].map do |h|
|
|
86
|
+
{ "type" => "command",
|
|
87
|
+
"command" => File.join(hook_dir, "plastic-#{h['name']}"),
|
|
88
|
+
"statusMessage" => h["status"] }
|
|
89
|
+
end,
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
out[event] = mapped.length == 1 ? mapped.first : mapped
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|