@zalom/plastic 1.7.1 → 1.9.0
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 +75 -535
- package/README.md +3 -2
- package/agents/plastic-intent-curator.md +2 -2
- package/deprecations.yml +10 -2
- package/hooks/{links-gate → edit-gates} +1 -1
- package/hooks/hooks.json +4 -44
- package/hooks/power-tools +8 -0
- package/package.json +1 -2
- package/scripts/codex-hook +17 -22
- package/scripts/doctor.rb +688 -186
- package/scripts/end-intent +65 -22
- package/scripts/hook-code-gate +13 -28
- package/scripts/hook-create-gate +10 -57
- package/scripts/hook-edit-gates +58 -0
- package/scripts/hook-links-gate +8 -31
- package/scripts/hook-lock-gate +13 -58
- package/scripts/hook-power-tools +38 -0
- package/scripts/hook-savepoint-pre +8 -11
- package/scripts/lib/bridge.rb +2 -2
- package/scripts/lib/edit_gates.rb +342 -0
- package/scripts/lib/hook_registry.rb +61 -29
- package/scripts/lib/installer_core.rb +100 -18
- package/scripts/lib/outcome_guard.rb +38 -0
- package/scripts/lib/qmd_hook.rb +21 -41
- package/scripts/lib/qmd_sync.rb +0 -15
- package/scripts/lib/revisions_writer.rb +1 -1
- package/scripts/maintenance-run +56 -2
- package/scripts/restore-intent-v1 +16 -11
- package/skills/auto/SKILL.md +15 -4
- package/skills/auto/evals/evals.json +2 -2
- package/skills/conventions/SKILL.md +31 -0
- package/skills/conventions/references/completion-and-done.md +40 -0
- package/skills/conventions/references/gates-and-enforcement.md +23 -0
- package/skills/conventions/references/knowledge-graph.md +47 -0
- package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
- package/skills/conventions/references/locks-and-worktrees.md +113 -0
- package/skills/conventions/references/maintenance-and-revisions.md +170 -0
- package/skills/conventions/references/roadmaps.md +44 -0
- package/skills/conventions/references/tiers-and-dispatch.md +134 -0
- package/skills/doctor/SKILL.md +59 -19
- package/skills/intent-brainstorming/SKILL.md +4 -0
- package/skills/intent-continuing/SKILL.md +4 -0
- package/skills/intent-creating/SKILL.md +7 -2
- package/skills/intent-ending/SKILL.md +20 -6
- package/skills/intent-executing/SKILL.md +10 -0
- package/skills/intent-linking/SKILL.md +4 -0
- package/skills/intent-locking/SKILL.md +4 -0
- package/skills/intent-planning/SKILL.md +7 -0
- package/skills/intent-savepoint/SKILL.md +4 -0
- package/skills/intent-speccing/SKILL.md +4 -0
- package/skills/intent-starting/SKILL.md +10 -0
- package/skills/project-creating/references/project-scaffolding.md +2 -2
- package/skills/roadmap/SKILL.md +4 -0
- package/skills/roadmap-continuing/SKILL.md +4 -0
- package/skills/skill-creating/SKILL.md +3 -0
- package/skills/skill-creating/references/defaults-first.md +23 -0
- package/skills/skill-creating/references/hooks.md +4 -1
- package/skills/store-curating/SKILL.md +8 -0
- package/skills/store-indexing/SKILL.md +8 -0
- package/skills/tutorial/references/track-2-auto.md +2 -3
- package/templates/agents.md +8 -0
- package/PLASTIC-reference.md +0 -138
- package/hooks/code-gate +0 -27
- package/hooks/create-gate +0 -3
- package/hooks/lock-gate +0 -21
- package/hooks/qmd-search +0 -8
- package/hooks/retrieval-gate +0 -10
- package/hooks/savepoint-pre +0 -10
- package/scripts/hook-qmd-search +0 -44
- package/scripts/hook-retrieval-gate +0 -148
- package/scripts/lib/legacy_bookend_amnesty.rb +0 -35
- package/scripts/lib/retrieval_gate.rb +0 -211
package/scripts/end-intent
CHANGED
|
@@ -63,6 +63,10 @@
|
|
|
63
63
|
# (git status itself failed: corrupt index, git missing, not a repo);
|
|
64
64
|
# refused before removal; pass --discard-worktree-changes to override
|
|
65
65
|
# deliberately.
|
|
66
|
+
# 6 the doctor per-intent structure gate refused (checklist incomplete,
|
|
67
|
+
# links out of projection, intent-file malformed, or a lifecycle artifact
|
|
68
|
+
# missing/placeholder); named reasons on stderr; authors nothing
|
|
69
|
+
# (intent 222).
|
|
66
70
|
|
|
67
71
|
require "fileutils"
|
|
68
72
|
require "date"
|
|
@@ -71,6 +75,7 @@ require "pathname"
|
|
|
71
75
|
require_relative "lib/bridge"
|
|
72
76
|
require_relative "lib/lock"
|
|
73
77
|
require_relative "lib/intent_validator"
|
|
78
|
+
require_relative "lib/outcome_guard"
|
|
74
79
|
|
|
75
80
|
DISPOSITIONS = %w[delivered abandoned].freeze
|
|
76
81
|
|
|
@@ -143,33 +148,69 @@ def resolve_intent_dir(store, id)
|
|
|
143
148
|
matches.first
|
|
144
149
|
end
|
|
145
150
|
|
|
146
|
-
# ---
|
|
151
|
+
# --- structure gate helper (intent 222) -------------------------------------
|
|
147
152
|
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
153
|
+
# Derive the TRUE plastic_home and this intent's store scope key from the --store
|
|
154
|
+
# directory end-intent was given, so the per-intent doctor gate (below) sees the SAME
|
|
155
|
+
# multi-store universe doctor.rb itself would (needed for its links-projection sub-check,
|
|
156
|
+
# which resolves cross-store refs). --store is either the global store
|
|
157
|
+
# (<plastic_home>/store) or a project store (<plastic_home>/projects/<slug>/store); the two
|
|
158
|
+
# shapes are told apart by whether store's grandparent directory is literally named
|
|
159
|
+
# "projects" (the StoreDiscovery convention, scripts/lib/store_discovery.rb), never by
|
|
160
|
+
# hardcoding ".plastic" as a name.
|
|
161
|
+
def resolve_plastic_home_and_scope(store)
|
|
162
|
+
parent = File.dirname(store)
|
|
163
|
+
grandparent = File.dirname(parent)
|
|
164
|
+
if File.basename(grandparent) == "projects"
|
|
165
|
+
slug = File.basename(parent)
|
|
166
|
+
[File.dirname(grandparent), "project:#{slug}"]
|
|
167
|
+
else
|
|
168
|
+
[parent, "global"]
|
|
162
169
|
end
|
|
170
|
+
end
|
|
163
171
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
172
|
+
# Doctor's per-intent structure gate (intent 222), a pre-write step: FAIL refuses the close
|
|
173
|
+
# (exit 6, authors nothing), WARN prints an advisory note and proceeds, PASS proceeds
|
|
174
|
+
# silently, and an unexpected exception from the gate call itself is rescued and treated as
|
|
175
|
+
# a skipped gate (fail-open on a crash: a broken gate must never wedge a close).
|
|
176
|
+
#
|
|
177
|
+
# `gate:` is an injected seam (mirrors run_disarm's own bridge_reader:/disarm: pattern
|
|
178
|
+
# below) defaulting to the real Doctor.new(...).run_intent_check(...) call; tests load this
|
|
179
|
+
# script in-process (as test/end_intent_disarm_toctou_test.rb already does for run_disarm)
|
|
180
|
+
# and inject a raising stub to prove the fail-open contract, with no eval/ENV/global seam.
|
|
181
|
+
#
|
|
182
|
+
# Disposition is deliberately NOT passed to the real gate call (intent 222 executor
|
|
183
|
+
# decision): the existing outcome-only guard immediately following this call already owns
|
|
184
|
+
# disposition-matching (exit 2), and intent_lifecycle_artifacts unconditionally checks
|
|
185
|
+
# outcome.md PRESENCE via Bridge.stage_file_present? regardless of disposition, so passing
|
|
186
|
+
# it here would make this gate re-check the SAME disposition match the old guard already
|
|
187
|
+
# owns, needlessly widening which exit-2 fixtures become exit-6. doctor.rb --intent called
|
|
188
|
+
# directly (with --disposition) still exercises the fold-in per D2/acceptance criteria.
|
|
189
|
+
def run_structure_gate(id, store:, gate: ->(gate_home, gate_scope) {
|
|
190
|
+
require_relative "doctor"
|
|
191
|
+
Doctor.new(plastic_home: gate_home).run_intent_check(id, store: gate_scope)
|
|
192
|
+
})
|
|
193
|
+
gate_home, gate_scope = resolve_plastic_home_and_scope(store)
|
|
194
|
+
gate_verdict = gate.call(gate_home, gate_scope)
|
|
195
|
+
case gate_verdict[:status]
|
|
196
|
+
when "fail"
|
|
197
|
+
gate_verdict[:checks].select { |c| c[:status] == "fail" }.each do |c|
|
|
198
|
+
detail = Array(c[:details]).any? ? " (#{Array(c[:details]).join("; ")})" : ""
|
|
199
|
+
warn "end-intent: structure gate refused: #{c[:name]}: #{c[:message]}#{detail}"
|
|
200
|
+
end
|
|
201
|
+
exit 6
|
|
202
|
+
when "warn"
|
|
203
|
+
gate_verdict[:checks].select { |c| c[:status] == "warn" }.each do |c|
|
|
204
|
+
detail = Array(c[:details]).any? ? " (#{Array(c[:details]).join("; ")})" : ""
|
|
205
|
+
warn "end-intent: structure gate warning (proceeding): #{c[:name]}: #{c[:message]}#{detail}"
|
|
206
|
+
end
|
|
168
207
|
end
|
|
169
|
-
|
|
170
|
-
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
warn "end-intent: structure gate crashed (#{e.message}); proceeding without it"
|
|
171
210
|
end
|
|
172
211
|
|
|
212
|
+
# outcome.md guard: see OutcomeGuard.reason (scripts/lib/outcome_guard.rb, extracted intent 222)
|
|
213
|
+
|
|
173
214
|
# --- intent-file `## Outcome` summary stamp (D2 step 1b) --------------------
|
|
174
215
|
#
|
|
175
216
|
# Replace the body of the intent file's `## Outcome` section with `summary`,
|
|
@@ -580,7 +621,9 @@ def main(argv)
|
|
|
580
621
|
exit 4
|
|
581
622
|
end
|
|
582
623
|
|
|
583
|
-
|
|
624
|
+
run_structure_gate(id, store: store)
|
|
625
|
+
|
|
626
|
+
reason = OutcomeGuard.reason(intent_dir, disposition)
|
|
584
627
|
if reason
|
|
585
628
|
warn "end-intent: #{reason}"
|
|
586
629
|
exit 2
|
package/scripts/hook-code-gate
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
# Usage: hook-code-gate <file_path>
|
|
5
|
+
# Usage: hook-code-gate <file_path> [session_id] [content]
|
|
6
6
|
# PreToolUse gate. Composes two independent block rules; EITHER blocks the edit:
|
|
7
7
|
# - Stage rule (intent 27): when auto mode is armed and the active intent has not
|
|
8
8
|
# reached How (plan.md + checklist.md), block edits to project code outside the store.
|
|
@@ -12,39 +12,24 @@
|
|
|
12
12
|
#
|
|
13
13
|
# Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
|
|
14
14
|
# No bridge = allow. Each rule fails open on its own conditions (see bridge.rb).
|
|
15
|
+
#
|
|
16
|
+
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
17
|
+
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
18
|
+
# (scripts/hook-edit-gates) so the two harnesses cannot drift.
|
|
15
19
|
|
|
16
|
-
require_relative "lib/
|
|
20
|
+
require_relative "lib/edit_gates"
|
|
17
21
|
|
|
18
22
|
file_path = ARGV[0]
|
|
19
23
|
exit 0 unless file_path && !file_path.empty?
|
|
20
24
|
|
|
21
25
|
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
|
|
28
|
-
begin
|
|
29
|
-
require "fileutils"
|
|
30
|
-
log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
|
|
31
|
-
FileUtils.mkdir_p(File.dirname(log))
|
|
32
|
-
File.open(log, "a") do |io|
|
|
33
|
-
io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{file_path}")
|
|
34
|
-
end
|
|
35
|
-
rescue StandardError
|
|
36
|
-
# the escape still applies; logging is best-effort
|
|
37
|
-
end
|
|
38
|
-
exit 0
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
42
|
-
bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
|
|
43
|
-
exit 0 unless bridge_data
|
|
44
|
-
|
|
45
|
-
reason = Bridge.code_gate_decision(bridge_data, file_path) ||
|
|
46
|
-
Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
|
|
47
|
-
exit 0 unless reason
|
|
27
|
+
ctx = EditGates::Context.new(
|
|
28
|
+
tool_name: "Write", session: session, gate_path: file_path, file_path: file_path,
|
|
29
|
+
content: ARGV[2], harness: "claude",
|
|
30
|
+
)
|
|
48
31
|
|
|
49
|
-
|
|
32
|
+
outcome = EditGates.code_gate(ctx)
|
|
33
|
+
exit 0 unless outcome
|
|
34
|
+
outcome.lines.each { |line| $stderr.puts line }
|
|
50
35
|
exit 2
|
package/scripts/hook-create-gate
CHANGED
|
@@ -26,9 +26,15 @@
|
|
|
26
26
|
# Pathless MCP mutation: validate the CURRENT on-disk file (the PostToolUse
|
|
27
27
|
# gate-check backstop validates the result); a
|
|
28
28
|
# missing file blocks (fail-safe).
|
|
29
|
+
#
|
|
30
|
+
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
31
|
+
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
32
|
+
# (scripts/hook-edit-gates) so the two harnesses cannot drift. Deliberately
|
|
33
|
+
# WITHOUT a top-level rescue, matching this script's behavior before the
|
|
34
|
+
# extraction: an internal exception here has never been caught.
|
|
29
35
|
|
|
30
36
|
require "json"
|
|
31
|
-
require_relative "lib/
|
|
37
|
+
require_relative "lib/edit_gates"
|
|
32
38
|
|
|
33
39
|
raw = $stdin.read rescue nil
|
|
34
40
|
exit 0 if raw.nil? || raw.strip.empty?
|
|
@@ -36,60 +42,7 @@ exit 0 if raw.nil? || raw.strip.empty?
|
|
|
36
42
|
payload = JSON.parse(raw) rescue nil
|
|
37
43
|
exit 0 unless payload.is_a?(Hash)
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
exit 0
|
|
41
|
-
|
|
42
|
-
# Precise path matcher: the target must be an intent file inside its own
|
|
43
|
-
# equally-named dir. Never matches sibling lifecycle files (spec.md, etc.) or
|
|
44
|
-
# unrelated store files.
|
|
45
|
-
abs = File.expand_path(path)
|
|
46
|
-
dir = File.dirname(abs)
|
|
47
|
-
is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
48
|
-
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
49
|
-
exit 0 unless is_intent_file
|
|
50
|
-
|
|
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 }
|
|
57
|
-
exit 2
|
|
58
|
-
end
|
|
59
|
-
|
|
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-intent-creating.")
|
|
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
|
-
|
|
90
|
-
exit 0 if result[:ok]
|
|
91
|
-
|
|
92
|
-
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
|
|
93
|
-
result[:errors].each { |e| $stderr.puts " #{e}" }
|
|
94
|
-
$stderr.puts "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
|
|
45
|
+
outcome = EditGates.create_gate(EditGates.context_from(payload))
|
|
46
|
+
exit 0 unless outcome
|
|
47
|
+
outcome.lines.each { |line| $stderr.puts line }
|
|
95
48
|
exit 2
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# The merged Claude PreToolUse edit-path dispatcher (intent 244). One registered
|
|
6
|
+
# hook, one Ruby process, one stdin parse, five gates in-process, in the fixed
|
|
7
|
+
# order savepoint-pre, lock-gate, code-gate, links-gate, create-gate (spec D-a).
|
|
8
|
+
# The first deny ends evaluation and its own shape is reproduced exactly: stderr
|
|
9
|
+
# plus exit 2 for code-gate/links-gate/create-gate, stdout permissionDecision
|
|
10
|
+
# JSON plus exit 0 for lock-gate (spec D-b). savepoint-pre runs first so its
|
|
11
|
+
# `started` ledger append stays unconditional, including on a call another gate
|
|
12
|
+
# then blocks (spec D-c).
|
|
13
|
+
#
|
|
14
|
+
# Fail-open everywhere (spec D-i): each gate runs inside its own rescue in
|
|
15
|
+
# EditGates.dispatch, and this file's top level rescues to exit 0, so a gate bug
|
|
16
|
+
# never denies and never silences a sibling gate.
|
|
17
|
+
#
|
|
18
|
+
# LOAD-BEARING SHAPE: doctor's claude_hooks_implemented_check reads THIS FILE as
|
|
19
|
+
# plain text and extracts the `when "<gate>"` labels of the `case gate` statement
|
|
20
|
+
# below, because a script with top-level side effects can never be required to
|
|
21
|
+
# introspect it (the same technique intent 200 used for scripts/codex-hook). If
|
|
22
|
+
# the case statement is reshaped, update that check in scripts/doctor.rb in the
|
|
23
|
+
# same change, or it will fail loudly by design.
|
|
24
|
+
|
|
25
|
+
require "json"
|
|
26
|
+
require_relative "lib/edit_gates"
|
|
27
|
+
|
|
28
|
+
def route(gate, ctx)
|
|
29
|
+
case gate
|
|
30
|
+
when "savepoint-pre" then EditGates.savepoint_pre(ctx)
|
|
31
|
+
when "lock-gate" then EditGates.lock_gate(ctx)
|
|
32
|
+
when "code-gate" then EditGates.code_gate(ctx)
|
|
33
|
+
when "links-gate" then EditGates.links_gate(ctx)
|
|
34
|
+
when "create-gate" then EditGates.create_gate(ctx)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
raw = begin
|
|
40
|
+
$stdin.read
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
exit 0 if raw.nil? || raw.strip.empty?
|
|
45
|
+
|
|
46
|
+
payload = begin
|
|
47
|
+
JSON.parse(raw)
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
exit 0 unless payload.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
exit EditGates.dispatch(ctx: EditGates.context_from(payload), route: method(:route))
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
# Dispatcher-internal failure: never impersonate a deny (spec D-i).
|
|
56
|
+
$stderr.puts "plastic edit-gates error: #{e.message}"
|
|
57
|
+
exit 0
|
|
58
|
+
end
|
package/scripts/hook-links-gate
CHANGED
|
@@ -13,9 +13,13 @@
|
|
|
13
13
|
# { "tool_input": { "file_path":, "content": | "old_string"/"new_string"/"replace_all": } }
|
|
14
14
|
# Exit 0 = allow (including every "cannot judge" case). Exit 2 = deny (message
|
|
15
15
|
# on stderr).
|
|
16
|
+
#
|
|
17
|
+
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
18
|
+
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
19
|
+
# (scripts/hook-edit-gates) so the two harnesses cannot drift.
|
|
16
20
|
|
|
17
21
|
require "json"
|
|
18
|
-
require_relative "lib/
|
|
22
|
+
require_relative "lib/edit_gates"
|
|
19
23
|
|
|
20
24
|
begin
|
|
21
25
|
raw = begin
|
|
@@ -32,36 +36,9 @@ begin
|
|
|
32
36
|
end
|
|
33
37
|
exit 0 unless payload.is_a?(Hash)
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
exit 0
|
|
37
|
-
|
|
38
|
-
abs = File.expand_path(path)
|
|
39
|
-
exit 0 unless LinksGate.intent_file?(abs)
|
|
40
|
-
|
|
41
|
-
input = payload["tool_input"] || payload["tool_params"] || {}
|
|
42
|
-
content = input["content"]
|
|
43
|
-
old_string = input["old_string"]
|
|
44
|
-
|
|
45
|
-
before_content = File.exist?(abs) ? File.read(abs) : ""
|
|
46
|
-
|
|
47
|
-
after_content =
|
|
48
|
-
if !content.nil?
|
|
49
|
-
content
|
|
50
|
-
elsif !old_string.nil?
|
|
51
|
-
exit 0 unless before_content.include?(old_string) # the Edit tool itself will fail; nothing to judge
|
|
52
|
-
input["replace_all"] ? before_content.gsub(old_string, input["new_string"].to_s)
|
|
53
|
-
: before_content.sub(old_string, input["new_string"].to_s)
|
|
54
|
-
else
|
|
55
|
-
exit 0 # pathless mutation (no visible proposal); cannot judge, allow
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
59
|
-
|
|
60
|
-
reason = LinksGate.decision(file_path: abs, before_content: before_content,
|
|
61
|
-
after_content: after_content, plastic_home: plastic_home)
|
|
62
|
-
exit 0 unless reason
|
|
63
|
-
|
|
64
|
-
$stderr.puts reason
|
|
39
|
+
outcome = EditGates.links_gate(EditGates.context_from(payload))
|
|
40
|
+
exit 0 unless outcome
|
|
41
|
+
outcome.lines.each { |line| $stderr.puts line }
|
|
65
42
|
exit 2
|
|
66
43
|
rescue => e
|
|
67
44
|
# Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
|
package/scripts/hook-lock-gate
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
# gated here (D2). Emits the PreToolUse JSON deny contract at exit 0; reserves
|
|
9
9
|
# non-zero exit for hook-internal errors only. Matcher Write|Edit|NotebookEdit, so
|
|
10
10
|
# reads never reach here.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
#
|
|
12
|
+
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
13
|
+
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
14
|
+
# (scripts/hook-edit-gates) so the two harnesses cannot drift.
|
|
15
|
+
|
|
16
|
+
require_relative "lib/edit_gates"
|
|
14
17
|
|
|
15
18
|
begin
|
|
16
19
|
file_path = ARGV[0]
|
|
@@ -22,62 +25,14 @@ begin
|
|
|
22
25
|
# "codex" here (intent 201 D2).
|
|
23
26
|
harness = (ARGV[2] unless ARGV[2].to_s.empty?) || "claude"
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# Allow path: refresh the lease for the session that holds this target's
|
|
29
|
-
# lock (owner or delegate). Best-effort, never blocks.
|
|
30
|
-
begin
|
|
31
|
-
dir = Bridge.intent_dir_for(file_path)
|
|
32
|
-
Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
|
|
33
|
-
rescue StandardError
|
|
34
|
-
# ignore
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Second, independent gate (intent 111 D7): the artifact-grain claim.
|
|
38
|
-
# Composes UNDER the delivery lock above; only reached once the delivery
|
|
39
|
-
# lock already allows. Dormant (nil) unless a claim file exists for this
|
|
40
|
-
# artifact, so AC7 (existing single-owner flows) is unaffected.
|
|
41
|
-
begin
|
|
42
|
-
dir = Bridge.intent_dir_for(file_path)
|
|
43
|
-
artifact = File.basename(file_path) if dir
|
|
44
|
-
if dir && artifact
|
|
45
|
-
claim_reason = Claim.claim_gate_reason(dir, artifact, session: session, harness: harness)
|
|
46
|
-
if claim_reason
|
|
47
|
-
print JSON.generate(
|
|
48
|
-
"hookSpecificOutput" => {
|
|
49
|
-
"hookEventName" => "PreToolUse",
|
|
50
|
-
"permissionDecision" => "deny",
|
|
51
|
-
"permissionDecisionReason" => claim_reason,
|
|
52
|
-
}
|
|
53
|
-
)
|
|
54
|
-
exit 0
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Fail-open surface (AC3): allow, but make the yielded/stale claim visible.
|
|
58
|
-
if Claim.fail_open?(dir, artifact)
|
|
59
|
-
$stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
|
|
60
|
-
"intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
|
|
61
|
-
"proceeds (see /plastic-lock status)"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Heartbeat the session's own claim on the allow path (best-effort).
|
|
65
|
-
Claim.heartbeat(dir, artifact, session: session) rescue nil
|
|
66
|
-
end
|
|
67
|
-
rescue StandardError
|
|
68
|
-
# ignore: a claim-gate bug must never block a write (fail open)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
exit 0
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
print JSON.generate(
|
|
75
|
-
"hookSpecificOutput" => {
|
|
76
|
-
"hookEventName" => "PreToolUse",
|
|
77
|
-
"permissionDecision" => "deny",
|
|
78
|
-
"permissionDecisionReason" => reason # "run /plastic-intent-starting to lock and begin"
|
|
79
|
-
}
|
|
28
|
+
ctx = EditGates::Context.new(
|
|
29
|
+
tool_name: "Write", session: session, gate_path: file_path, file_path: file_path,
|
|
30
|
+
harness: harness,
|
|
80
31
|
)
|
|
32
|
+
|
|
33
|
+
outcome = EditGates.lock_gate(ctx)
|
|
34
|
+
exit 0 unless outcome
|
|
35
|
+
print outcome.stdout
|
|
81
36
|
exit 0
|
|
82
37
|
rescue => e
|
|
83
38
|
# Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# power-tools UserPromptSubmit hook (intents 66, 66b, 187, 246). Emits the
|
|
6
|
+
# PowerTools mandate, one recommendation line per present tool, as
|
|
7
|
+
# hookSpecificOutput. Silent no-op (exit 0) when no power tool is present or
|
|
8
|
+
# anything goes wrong. It parses no prompt: the mandate depends on tool presence
|
|
9
|
+
# alone, so there is nothing to gate on prompt triviality. Intent 246 removed the
|
|
10
|
+
# qmd hit search this hook used to run first.
|
|
11
|
+
require "json"
|
|
12
|
+
require "timeout"
|
|
13
|
+
require_relative "lib/qmd_hook"
|
|
14
|
+
|
|
15
|
+
# Drain stdin so the caller's write never blocks or breaks on a closed pipe. The
|
|
16
|
+
# payload is deliberately not parsed.
|
|
17
|
+
begin
|
|
18
|
+
STDIN.read
|
|
19
|
+
rescue StandardError
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
context = begin
|
|
24
|
+
Timeout.timeout(2) do
|
|
25
|
+
QmdHook.run(cwd: Dir.pwd)
|
|
26
|
+
end
|
|
27
|
+
rescue Exception
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
exit 0 if context.nil? || context.strip.empty?
|
|
32
|
+
|
|
33
|
+
puts JSON.generate(
|
|
34
|
+
"hookSpecificOutput" => {
|
|
35
|
+
"hookEventName" => "UserPromptSubmit",
|
|
36
|
+
"additionalContext" => context,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
@@ -13,20 +13,17 @@
|
|
|
13
13
|
# write: any non-match or failure exits 0. The append is idempotent and only
|
|
14
14
|
# fires while the stage is genuinely starting (the artifact is not yet a real,
|
|
15
15
|
# non-placeholder file), so re-edits add nothing.
|
|
16
|
+
#
|
|
17
|
+
# Thin CLI wrapper (intent 244): the gate logic itself lives in
|
|
18
|
+
# scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
|
|
19
|
+
# (scripts/hook-edit-gates) so the two harnesses cannot drift.
|
|
16
20
|
|
|
17
|
-
require_relative "lib/
|
|
21
|
+
require_relative "lib/edit_gates"
|
|
18
22
|
|
|
19
23
|
file_path = ARGV[0]
|
|
20
24
|
exit 0 if file_path.nil? || file_path.empty?
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
begin
|
|
27
|
-
Bridge.append_started_savepoint(intent_dir, file_path_abs)
|
|
28
|
-
rescue StandardError
|
|
29
|
-
# best-effort; the ledger is rebuildable and the post line still lands
|
|
30
|
-
end
|
|
31
|
-
|
|
26
|
+
EditGates.savepoint_pre(
|
|
27
|
+
EditGates::Context.new(tool_name: "Write", file_path: file_path, harness: "claude")
|
|
28
|
+
)
|
|
32
29
|
exit 0
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -1598,8 +1598,8 @@ module Bridge
|
|
|
1598
1598
|
end
|
|
1599
1599
|
|
|
1600
1600
|
# A TRAILING `# plastic-ok` shell comment: the auditable escape for
|
|
1601
|
-
# sanctioned bash/interpreter writes
|
|
1602
|
-
#
|
|
1601
|
+
# sanctioned bash/interpreter writes. The hook logs every use to
|
|
1602
|
+
# ~/.plastic/.cache/gate-escapes.log.
|
|
1603
1603
|
PLASTIC_OK_RE = /(?:\A|\s)#\s*plastic-ok\s*\z/.freeze
|
|
1604
1604
|
|
|
1605
1605
|
def self.bash_escape?(command)
|