@zalom/plastic 1.0.0-beta.23 → 1.0.0-beta.25
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 +46 -31
- 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 +47 -2
- 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 +25 -2
- package/skills/doctor/SKILL.md +4 -1
- package/skills/intent-starting/SKILL.md +16 -3
- package/skills/lock/SKILL.md +41 -0
package/PLASTIC.md
CHANGED
|
@@ -283,32 +283,15 @@ hygiene after each intent. Advisory self-check, not hard-verifiable.
|
|
|
283
283
|
|
|
284
284
|
## Retrieval Gate
|
|
285
285
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
reindex is fired so the next turn enforces against a fresh index; reindex is never
|
|
296
|
-
synchronous. When QMD is absent, the search is allowed.
|
|
297
|
-
- Reading a known target (the Read tool, bash `cat`/`head`/`tail`) and structural discovery
|
|
298
|
-
(the Glob tool, bash `find`/`ls`) are always allowed, including over the store. QMD cannot
|
|
299
|
-
list directories or hand back one specific file, so these are never gated.
|
|
300
|
-
- Code is never hard-gated here. Symbolic code navigation via Serena is a soft prompt mandate
|
|
301
|
-
(the UserPromptSubmit power-tools hook), not a block: content grep over code is allowed,
|
|
302
|
-
because Serena navigates symbols and cannot grep arbitrary strings.
|
|
303
|
-
- QMD failure model. Absent or stale degrades to allow (stale also fires the background
|
|
304
|
-
reindex). A broken QMD, where the freshness probe errors or times out, also fails open, and
|
|
305
|
-
the hook emits a one-line warning so a degraded QMD is visible rather than silent.
|
|
306
|
-
- Bypass: append a trailing `# qmd-ok` shell comment to a Bash command when you attempted
|
|
307
|
-
discovery and it did not serve you (no hits, or results that do not answer your need by your
|
|
308
|
-
reading of the snippets, not their score). A quoted or echoed occurrence does not bypass.
|
|
309
|
-
Bypasses are logged. The gate enforces that discovery was attempted, never that it succeeded.
|
|
310
|
-
- Scope: only the agent's tool calls. Ruby `File.read` inside a script is invisible to the gate
|
|
311
|
-
and is out of scope by design.
|
|
286
|
+
Advisory. Hard gates guard writes, locks, and structure, never reads or searches. Read,
|
|
287
|
+
Grep, Glob, and bash search are always allowed, including over the stores. When QMD is
|
|
288
|
+
present and fresh, a content search over store markdown receives an advisory hint pointing
|
|
289
|
+
at `qmd search` alongside its result; when QMD is present but stale, a background reindex
|
|
290
|
+
fires so the next turn's hint runs against a fresh index (never synchronous). QMD and
|
|
291
|
+
Serena are recommendations, not obligations: the UserPromptSubmit power-tools hook appends
|
|
292
|
+
one recommendation line per present tool. The legacy trailing `# qmd-ok` token is still
|
|
293
|
+
accepted on Bash commands and simply silences the hint. Scope stays the agent's own tool
|
|
294
|
+
calls; Ruby `File.read` inside a script is invisible to the hook by design.
|
|
312
295
|
|
|
313
296
|
## Context-economy measurement buckets (84a)
|
|
314
297
|
|
|
@@ -332,11 +315,28 @@ Hard blocking — hooks exit code 2 on gate failure.
|
|
|
332
315
|
|
|
333
316
|
## Delivery Isolation and the Single-Owner Lock
|
|
334
317
|
|
|
335
|
-
Exactly one session or agent develops an intent's delivery at a time. Ownership is
|
|
336
|
-
session
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
318
|
+
Exactly one session or agent develops an intent's delivery at a time. Ownership is
|
|
319
|
+
session-keyed and durable: arming acquires `delivery.lock` inside the intent directory
|
|
320
|
+
(atomically, O_EXCL), recording the owner session, the host, the acquired-at time, a
|
|
321
|
+
delegates list, and the lock type. Liveness is a lease: the owner's hooks refresh the lock
|
|
322
|
+
file's mtime on tool activity, and the lock counts as stale only when that heartbeat is
|
|
323
|
+
older than the TTL. No process id is consulted anywhere. The /tmp session bridge is a cache
|
|
324
|
+
of this state; on any disagreement, or when the bridge is missing, the lock file wins.
|
|
325
|
+
Another session that finds a fresh lock backs off; a stale lock is reclaimed only by
|
|
326
|
+
explicit takeover, which replaces the lock and appends an audit line to the intent's
|
|
327
|
+
savepoint.md. Subagents spawned by the owner write under the owner's lock once registered
|
|
328
|
+
as delegates. Disarm clears the lock; the End tail is ordered: verify, merge and remove
|
|
329
|
+
worktrees, clear the lock, and only then is the bridge purge-eligible. Repair is one
|
|
330
|
+
idempotent function with two entry points: the `plastic-lock` command (status, fix,
|
|
331
|
+
release, reclaim, delegate) and `/plastic-intent-starting`, so boarding self-heals. This is
|
|
332
|
+
mandatory, not a convention.
|
|
333
|
+
|
|
334
|
+
Two locks share this schema (the two-lock doctrine): `delivery.lock` (exclusive, one owner
|
|
335
|
+
plus delegates) and the future `maintenance.lock` (short TTL, structural move-and-record
|
|
336
|
+
only). They are mutually exclusive in either direction; maintenance is allowed at any
|
|
337
|
+
lifecycle stage provided no delivery lock is held. Intent 108 ships the delivery lock and
|
|
338
|
+
the mutual-exclusion seam; the maintenance lock implementation follows intent 93 in a
|
|
339
|
+
chained intent.
|
|
340
340
|
|
|
341
341
|
Every code-touching intent gets its own git worktree named `{id}--{slug}`, and all code edits
|
|
342
342
|
for that intent happen only inside it. Plastic provisions the worktree deterministically: it
|
|
@@ -350,6 +350,21 @@ Provisioning fails open for intents that touch no project code (pure research or
|
|
|
350
350
|
intents in the global store, or a non-git repo): those get the lock only, and the worktree
|
|
351
351
|
block stays unprovisioned. The fail-open path is always logged, never silent.
|
|
352
352
|
|
|
353
|
+
### Intent delivery, station by station
|
|
354
|
+
|
|
355
|
+
How one intent travels from boarding to Done, and what the lock, bridge, and gates do at
|
|
356
|
+
each station.
|
|
357
|
+
|
|
358
|
+
| Station | Delivered artifact | Lock and bridge steps | Pre-stage gate | Post-stage record |
|
|
359
|
+
|---|---|---|---|---|
|
|
360
|
+
| Start (board) | none (a procedure, not a stage) | `plastic-lock fix` self-heals stale, corrupt, or legacy state; arm acquires `delivery.lock` (O_EXCL, session-keyed), provisions the code worktree, writes the bridge cache | lock-gate denies any write into an active intent dir without this intent's lock; every deny names the resolving command | savepoint confirms the boarding station |
|
|
361
|
+
| What (create) | `<id>--<slug>.md`, born complete | no lock yet; no bridge | create-gate validates the proposed intent content (Write, Edit, and MCP edits) | savepoint `What` line; intent listed in INDEX `## Active` |
|
|
362
|
+
| Why | `spec.md` | owner writes refresh the lease (lock file mtime heartbeat) | gate-check requires the intent file with `## Intent` before spec.md; lock-gate admits only the owner or a delegate | savepoint `Why started`, `Why spec.md created` |
|
|
363
|
+
| How | `plan.md`, `actions/`, `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md plus checklist.md exist | gate-check requires spec.md before plan.md, and plan.md plus actions/ before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
|
|
364
|
+
| Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock; bash, interpreter, and MCP writes gated the same way | code-gate, worktree-gate, bash-gate, lock-gate | checklist boxes; savepoint milestones |
|
|
365
|
+
| End (done) | `outcome.md`, INDEX moves to Completed | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, only then is the bridge purge-eligible | gate-check blocks outcome.md while checklist items are unchecked | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
|
|
366
|
+
| Maintenance (any stage) | `revisions.md` move-and-record entries | future `maintenance.lock` (short TTL), mutually exclusive with `delivery.lock` in either direction; 108 ships the schema seam only, the implementation follows intent 93 in a chained intent | acquisition refuses while the other lock type is fresh; a terminal intent with no lock held is read-only | dated, rule-tagged `revisions.md` entry; savepoint untouched |
|
|
367
|
+
|
|
353
368
|
## Deprecation Process
|
|
354
369
|
|
|
355
370
|
Deprecations live in `deprecations.yml` and are shown at SessionStart. While Plastic is
|
package/hooks/code-gate
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
INPUT=$(cat)
|
|
3
|
-
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
3
|
+
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
4
|
+
data = JSON.parse(STDIN.read)
|
|
5
|
+
ti = data["tool_input"] || data["tool_params"] || {}
|
|
6
|
+
path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
|
|
7
|
+
if !path.empty? && !path.start_with?("/")
|
|
8
|
+
root = ti["project_root"] || data["cwd"] || ""
|
|
9
|
+
path = File.join(root, path) unless root.empty?
|
|
10
|
+
end
|
|
11
|
+
puts path
|
|
12
|
+
' 2>/dev/null)
|
|
4
13
|
|
|
5
14
|
if [ -z "$FILE_PATH" ]; then
|
|
6
15
|
exit 0
|
package/hooks/hooks.json
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"PreCompact": [
|
|
21
21
|
{
|
|
22
|
-
"matcher": "
|
|
22
|
+
"matcher": "",
|
|
23
23
|
"hooks": [
|
|
24
24
|
{
|
|
25
25
|
"type": "command",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
],
|
|
32
32
|
"PreToolUse": [
|
|
33
33
|
{
|
|
34
|
-
"matcher": "Write|Edit|NotebookEdit",
|
|
34
|
+
"matcher": "Write|Edit|NotebookEdit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
|
|
35
35
|
"hooks": [
|
|
36
36
|
{
|
|
37
37
|
"type": "command",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
]
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
|
-
"matcher": "Write",
|
|
59
|
+
"matcher": "Write|Edit|mcp__serena__replace_content|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol|mcp__serena__safe_delete_symbol|mcp__serena__rename_symbol",
|
|
60
60
|
"hooks": [
|
|
61
61
|
{
|
|
62
62
|
"type": "command",
|
package/hooks/lock-gate
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
INPUT=$(cat)
|
|
3
|
-
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
3
|
+
FILE_PATH=$(echo "$INPUT" | ruby -rjson -e '
|
|
4
|
+
data = JSON.parse(STDIN.read)
|
|
5
|
+
ti = data["tool_input"] || data["tool_params"] || {}
|
|
6
|
+
path = ti["file_path"] || ti["notebook_path"] || ti["relative_path"] || ""
|
|
7
|
+
if !path.empty? && !path.start_with?("/")
|
|
8
|
+
root = ti["project_root"] || data["cwd"] || ""
|
|
9
|
+
path = File.join(root, path) unless root.empty?
|
|
10
|
+
end
|
|
11
|
+
puts path
|
|
12
|
+
' 2>/dev/null)
|
|
4
13
|
|
|
5
14
|
if [ -z "$FILE_PATH" ]; then
|
|
6
15
|
exit 0
|
package/package.json
CHANGED
package/scripts/doctor.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative "lib/intent_validator"
|
|
|
20
20
|
require_relative "lib/graph_rebuild"
|
|
21
21
|
require_relative "lib/links_projection"
|
|
22
22
|
require_relative "lib/links_section"
|
|
23
|
+
require_relative "lib/hook_registry"
|
|
23
24
|
|
|
24
25
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
25
26
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -472,7 +473,10 @@ class Doctor
|
|
|
472
473
|
category: "conventions", name: "section_structure", status: "warn",
|
|
473
474
|
message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
|
|
474
475
|
details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
|
|
475
|
-
fixable:
|
|
476
|
+
fixable: true,
|
|
477
|
+
fix_hint: "Dispatch plastic-intent-curator to relocate each unsanctioned section into the " \
|
|
478
|
+
"intent's revisions.md via move-and-record (a missing required section is restored " \
|
|
479
|
+
"or reprojected instead); see PLASTIC.md > Structural maintenance and revisions.md"
|
|
476
480
|
)
|
|
477
481
|
end
|
|
478
482
|
|
|
@@ -698,7 +702,9 @@ class Doctor
|
|
|
698
702
|
checks << graph_finding_check(
|
|
699
703
|
"graph_i4_danglers", i4,
|
|
700
704
|
"Every sources/chain id resolves to a real intent (I4)",
|
|
701
|
-
"
|
|
705
|
+
"Dispatch plastic-intent-curator to record the dangling sources/chain edge as a " \
|
|
706
|
+
"broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
|
|
707
|
+
"PLASTIC.md > Structural maintenance and revisions.md), or restore the missing intent"
|
|
702
708
|
)
|
|
703
709
|
checks
|
|
704
710
|
end
|
|
@@ -822,6 +828,45 @@ class Doctor
|
|
|
822
828
|
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
823
829
|
)
|
|
824
830
|
end
|
|
831
|
+
|
|
832
|
+
# hooks_match_registry (intent 108, D7): the live settings must carry
|
|
833
|
+
# EXACTLY the registrations HookRegistry defines; any drift (a missing
|
|
834
|
+
# gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
|
|
835
|
+
# dead once already.
|
|
836
|
+
expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
|
|
837
|
+
diffs = []
|
|
838
|
+
expected.each do |event, group|
|
|
839
|
+
groups = group.is_a?(Array) ? group : [group]
|
|
840
|
+
live = settings.dig("hooks", event) || []
|
|
841
|
+
groups.each do |g|
|
|
842
|
+
match = live.find { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
|
|
843
|
+
wanted = g["hooks"].map { |h| h["command"] }
|
|
844
|
+
got = match ? Array(match["hooks"]).map { |h| h["command"] } : []
|
|
845
|
+
missing = wanted - got
|
|
846
|
+
diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
|
|
850
|
+
Array(groups).flat_map do |g|
|
|
851
|
+
next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
|
|
852
|
+
g["hooks"].map { |h| h["command"].to_s }.select { |c| c.include?("plastic-") }
|
|
853
|
+
.map { |c| "#{event}: #{c}" }
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
expected_cmds = expected.values.flat_map { |g| g.is_a?(Array) ? g : [g] }
|
|
857
|
+
.flat_map { |g| g["hooks"].map { |h| h["command"] } }
|
|
858
|
+
strays = live_plastic.reject { |lp| expected_cmds.any? { |c| lp.end_with?(c) } }
|
|
859
|
+
diffs.concat(strays.map { |s| "stray: #{s}" })
|
|
860
|
+
|
|
861
|
+
checks << if diffs.empty?
|
|
862
|
+
check(category: "agent_registration", name: "hooks_match_registry",
|
|
863
|
+
status: "pass", message: "settings.json hooks match HookRegistry")
|
|
864
|
+
else
|
|
865
|
+
check(category: "agent_registration", name: "hooks_match_registry",
|
|
866
|
+
status: "fail", message: "#{diffs.size} hook registration(s) diverge from HookRegistry",
|
|
867
|
+
details: diffs, fixable: true,
|
|
868
|
+
fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
|
|
869
|
+
end
|
|
825
870
|
end
|
|
826
871
|
|
|
827
872
|
# skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
|
package/scripts/hook-bash-gate
CHANGED
|
@@ -28,13 +28,31 @@ 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 (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
32
31
|
session = payload["session_id"]
|
|
33
32
|
session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.to_s.empty?
|
|
33
|
+
|
|
34
|
+
# Auditable escape (intent 108, D7): a trailing `# plastic-ok` allows the
|
|
35
|
+
# command and logs it, so sanctioned writes are visible, not silent.
|
|
36
|
+
if Bridge.bash_escape?(command)
|
|
37
|
+
begin
|
|
38
|
+
require "fileutils"
|
|
39
|
+
log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
41
|
+
File.open(log, "a") do |io|
|
|
42
|
+
io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{command.gsub(/\s+/, ' ').strip}")
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError
|
|
45
|
+
# the escape still applies; logging is best-effort
|
|
46
|
+
end
|
|
47
|
+
exit 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
51
|
+
# A nil bridge does NOT short-circuit (intent 108): the lock gate decides from
|
|
52
|
+
# the durable delivery.lock file, so it must run even without a bridge cache.
|
|
34
53
|
bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
|
|
35
|
-
exit 0 unless bridge_data
|
|
36
54
|
|
|
37
|
-
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
|
|
55
|
+
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd, session: session)
|
|
38
56
|
exit 0 unless reason
|
|
39
57
|
|
|
40
58
|
$stderr.puts "PLASTIC GATE — #{reason}"
|
package/scripts/hook-create-gate
CHANGED
|
@@ -19,8 +19,13 @@
|
|
|
19
19
|
# Reads the Claude Code PreToolUse payload as JSON on STDIN:
|
|
20
20
|
# { "tool_input": { "file_path": "...", "content": "..." } }
|
|
21
21
|
# Empty / unparseable / non-matching path => exit 0 (cannot judge, allow).
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# Three payload shapes on a matching path (intent 108, D7):
|
|
23
|
+
# Write (content): validate the proposed content.
|
|
24
|
+
# Edit (old_string): simulate the replacement on the on-disk file and
|
|
25
|
+
# validate the RESULT; a missing file blocks.
|
|
26
|
+
# Pathless MCP mutation: validate the CURRENT on-disk file (the PostToolUse
|
|
27
|
+
# gate-check backstop validates the result); a
|
|
28
|
+
# missing file blocks (fail-safe).
|
|
24
29
|
|
|
25
30
|
require "json"
|
|
26
31
|
require_relative "lib/intent_validator"
|
|
@@ -43,14 +48,45 @@ is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
|
43
48
|
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
44
49
|
exit 0 unless is_intent_file
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
input = payload["tool_input"] || payload["tool_params"] || {}
|
|
52
|
+
content = input["content"]
|
|
53
|
+
old_string = input["old_string"]
|
|
54
|
+
|
|
55
|
+
def block!(lines)
|
|
56
|
+
Array(lines).each { |l| $stderr.puts l }
|
|
50
57
|
exit 2
|
|
51
58
|
end
|
|
52
59
|
|
|
53
|
-
result =
|
|
60
|
+
result =
|
|
61
|
+
if !content.nil?
|
|
62
|
+
# Write: validate the proposed content (intent 60b, unchanged).
|
|
63
|
+
IntentValidator.validate_content(content)
|
|
64
|
+
elsif !old_string.nil?
|
|
65
|
+
# Edit (intent 108, D7): simulate the replacement on the on-disk file and
|
|
66
|
+
# validate the RESULT, so an Edit can no longer sneak an intent file past
|
|
67
|
+
# the validator.
|
|
68
|
+
unless File.exist?(abs)
|
|
69
|
+
block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
|
|
70
|
+
"create intents via new-intent / plastic-creating-intent.")
|
|
71
|
+
end
|
|
72
|
+
current = File.read(abs)
|
|
73
|
+
unless current.include?(old_string)
|
|
74
|
+
exit 0 # the Edit tool itself will fail; nothing lands, nothing to judge
|
|
75
|
+
end
|
|
76
|
+
simulated = input["replace_all"] ? current.gsub(old_string, input["new_string"].to_s)
|
|
77
|
+
: current.sub(old_string, input["new_string"].to_s)
|
|
78
|
+
IntentValidator.validate_content(simulated)
|
|
79
|
+
else
|
|
80
|
+
# Pathless mutation (MCP symbolic edits): the proposal is not visible at
|
|
81
|
+
# PreToolUse. Judge the CURRENT file: valid -> allow (the PostToolUse
|
|
82
|
+
# gate-check backstop validates the result), invalid/missing -> block.
|
|
83
|
+
unless File.exist?(abs)
|
|
84
|
+
block!("PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
|
|
85
|
+
"content and no file exists; refusing to allow an unvalidated intent write.")
|
|
86
|
+
end
|
|
87
|
+
IntentValidator.validate_content(File.read(abs))
|
|
88
|
+
end
|
|
89
|
+
|
|
54
90
|
exit 0 if result[:ok]
|
|
55
91
|
|
|
56
92
|
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
|
package/scripts/hook-gate-check
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
require "json"
|
|
10
10
|
require_relative "lib/bridge"
|
|
11
11
|
require_relative "lib/intent_validator"
|
|
12
|
+
require_relative "lib/lock"
|
|
12
13
|
|
|
13
14
|
file_path = ARGV[0]
|
|
14
15
|
exit 0 unless file_path && !file_path.empty?
|
|
@@ -72,6 +73,15 @@ intent_dir = "#{store}/#{dir}"
|
|
|
72
73
|
# Normalize both paths for comparison
|
|
73
74
|
bridge_intent_dir_abs = File.expand_path(intent_dir)
|
|
74
75
|
|
|
76
|
+
# Lease heartbeat (intent 108, D1): every write by the session that owns (or
|
|
77
|
+
# delegates under) the lock refreshes the delivery.lock mtime. Best-effort:
|
|
78
|
+
# a heartbeat failure must never break the gate hook.
|
|
79
|
+
begin
|
|
80
|
+
Lock.heartbeat(bridge_intent_dir_abs, session: session)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
# ignore
|
|
83
|
+
end
|
|
84
|
+
|
|
75
85
|
# Not inside intent dir = not our business
|
|
76
86
|
exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
|
|
77
87
|
|
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
|