@zalom/plastic 1.8.0 → 1.10.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 +94 -556
- package/README.md +18 -6
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-intent-curator.md +2 -2
- package/agents/plastic-intent-discovery.md +7 -0
- package/bin/plastic.js +17 -8
- package/deprecations.yml +10 -2
- package/hooks/auto-arm +2 -2
- package/hooks/bash-gate +1 -1
- package/hooks/check-update +1 -1
- package/hooks/continue +2 -2
- package/hooks/edit-gates +3 -0
- package/hooks/future-intent-check +2 -2
- package/hooks/gate-check +3 -3
- package/hooks/hooks.json +4 -44
- package/hooks/power-tools +8 -0
- package/hooks/session-start +1 -1
- package/package.json +1 -2
- package/scripts/codex-hook +57 -118
- package/scripts/doctor.rb +146 -1012
- package/scripts/exec-worktree +103 -0
- package/scripts/hash-intent +1 -1
- package/scripts/hook-bash-gate +19 -0
- package/scripts/hook-code-gate +16 -28
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +13 -57
- package/scripts/hook-edit-gates +58 -0
- package/scripts/hook-gate-check +17 -0
- package/scripts/hook-links-gate +11 -31
- package/scripts/hook-lock-gate +19 -60
- package/scripts/hook-power-tools +38 -0
- package/scripts/hook-savepoint-pre +11 -11
- package/scripts/hook-session-start +21 -15
- package/scripts/lib/apply_patch_envelope.rb +46 -13
- package/scripts/lib/bridge.rb +85 -17
- package/scripts/lib/codex_edit_gates.rb +138 -0
- package/scripts/lib/doctor_core.rb +1087 -0
- package/scripts/lib/edit_gates.rb +398 -0
- package/scripts/lib/exec_worktree.rb +325 -0
- package/scripts/lib/harness_text.rb +57 -0
- package/scripts/lib/hook_registry.rb +64 -28
- package/scripts/lib/installer_core.rb +166 -24
- package/scripts/lib/lock.rb +196 -47
- 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/lib/ruby_probe.rb +60 -0
- package/scripts/lib/scaffold_intent.rb +392 -0
- package/scripts/lib/spec_header.rb +83 -0
- package/scripts/lib/start_intent.rb +296 -0
- package/scripts/lib/verify_intent.rb +262 -0
- package/scripts/lib/worktree.rb +15 -1
- package/scripts/link-suggest +1 -1
- package/scripts/maintenance-run +5 -5
- package/scripts/migrate-to-global +2 -2
- package/scripts/restore-intent-v1 +17 -12
- package/scripts/scaffold-intent +120 -0
- package/scripts/start-intent +89 -0
- package/scripts/verify-intent +73 -0
- package/skills/agent-advisor/SKILL.md +5 -5
- package/skills/auto/SKILL.md +52 -32
- package/skills/auto/evals/evals.json +2 -2
- package/skills/auto/references/agent-architecture.md +1 -1
- package/skills/auto/references/agent-report-contract.md +1 -1
- package/skills/auto/references/human-report-contract.md +22 -3
- package/skills/auto/references/tiers.md +24 -2
- package/skills/conventions/SKILL.md +31 -0
- package/skills/conventions/references/completion-and-done.md +43 -0
- package/skills/conventions/references/gates-and-enforcement.md +39 -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 +135 -0
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +15 -6
- package/skills/doctor/references/gates-stuck-detection.md +13 -8
- package/skills/doctor/report.md +1 -1
- package/skills/install/SKILL.md +1 -1
- package/skills/intent-brainstorming/SKILL.md +4 -2
- package/skills/intent-continuing/SKILL.md +4 -0
- package/skills/intent-creating/SKILL.md +13 -8
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-discovering/SKILL.md +10 -3
- package/skills/intent-ending/SKILL.md +12 -7
- package/skills/intent-executing/SKILL.md +37 -19
- package/skills/intent-grilling/SKILL.md +5 -3
- package/skills/intent-linking/SKILL.md +4 -0
- package/skills/intent-locking/SKILL.md +4 -0
- package/skills/intent-planning/SKILL.md +14 -3
- package/skills/intent-researching/SKILL.md +0 -2
- package/skills/intent-savepoint/SKILL.md +4 -0
- package/skills/intent-speccing/SKILL.md +4 -0
- package/skills/intent-starting/SKILL.md +20 -2
- package/skills/project-creating/SKILL.md +0 -2
- package/skills/project-creating/references/project-scaffolding.md +3 -3
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/promotion-and-tagging.md +14 -8
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +4 -0
- package/skills/roadmap-continuing/SKILL.md +4 -0
- package/skills/skill-creating/SKILL.md +8 -2
- 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 +16 -5
- package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
- package/skills/tutorial/references/track-1-guided.md +2 -2
- package/skills/tutorial/references/track-2-auto.md +10 -8
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
- package/skills/uninstall/SKILL.md +6 -9
- package/templates/agents.md +12 -12
- package/templates/config.yml +6 -7
- package/templates/index.md +6 -3
- package/templates/spec.md +1 -1
- package/PLASTIC-reference.md +0 -138
- package/hooks/code-gate +0 -27
- package/hooks/create-gate +0 -3
- package/hooks/links-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/retrieval_gate.rb +0 -211
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# EditGates (intent 244): the five Claude PreToolUse edit-path gates as plain
|
|
5
|
+
# functions over one parsed payload. Three callers share these functions so the
|
|
6
|
+
# harnesses cannot drift: scripts/hook-edit-gates (the merged Claude dispatcher),
|
|
7
|
+
# scripts/lib/codex_edit_gates.rb (the merged Codex dispatcher, intent 251), and
|
|
8
|
+
# the retained scripts/hook-code-gate / hook-lock-gate / hook-savepoint-pre /
|
|
9
|
+
# hook-links-gate / hook-create-gate CLI wrappers, which no longer have a
|
|
10
|
+
# production caller on either harness and remain as the per-gate isolation
|
|
11
|
+
# surface for the hook test suite.
|
|
12
|
+
#
|
|
13
|
+
# A gate returns nil to allow, or a Deny. Two deny shapes coexist deliberately
|
|
14
|
+
# (spec D-b): :stderr (stderr lines plus exit 2, code-gate / links-gate /
|
|
15
|
+
# create-gate) and :json (stdout JSON plus exit 0, lock-gate, which reserves a
|
|
16
|
+
# non-zero exit for hook-internal errors). Nothing here normalizes them.
|
|
17
|
+
|
|
18
|
+
require "json"
|
|
19
|
+
require "time"
|
|
20
|
+
require_relative "hook_registry"
|
|
21
|
+
require_relative "bridge"
|
|
22
|
+
require_relative "lock"
|
|
23
|
+
require_relative "links_gate"
|
|
24
|
+
require_relative "intent_validator"
|
|
25
|
+
|
|
26
|
+
module EditGates
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
ALLOW = nil
|
|
30
|
+
|
|
31
|
+
Deny = Struct.new(:shape, :lines, :stdout, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
# Everything the five gates read out of one PreToolUse payload (spec D-g).
|
|
34
|
+
# gate_path - code-gate and lock-gate: file_path || notebook_path ||
|
|
35
|
+
# relative_path, absolutized against tool_input.project_root ||
|
|
36
|
+
# payload.cwd when relative. Exactly what the old Bash shims
|
|
37
|
+
# computed with their `ruby -rjson -e` one-liners.
|
|
38
|
+
# file_path - savepoint-pre, links-gate, create-gate: tool_input.file_path
|
|
39
|
+
# only, raw. Preserving this narrower extraction is what keeps
|
|
40
|
+
# create-gate's dormant Serena gap byte-for-byte intact.
|
|
41
|
+
# content - the RAW tool_input.content (nil when the key is absent, ""
|
|
42
|
+
# when it is explicitly empty). Kept unmixed with new_string so
|
|
43
|
+
# links-gate and create-gate's `!content.nil?` branch keeps its
|
|
44
|
+
# exact original meaning; code-gate computes its own combined
|
|
45
|
+
# content-or-new_string-or-"" value locally, mirroring what its
|
|
46
|
+
# old Bash shim computed independently for ARGV[2].
|
|
47
|
+
Context = Struct.new(:tool_name, :session, :gate_path, :file_path, :savepoint_path,
|
|
48
|
+
:content, :old_string, :new_string, :replace_all, :harness,
|
|
49
|
+
keyword_init: true) do
|
|
50
|
+
# links-gate and create-gate branch on content.nil? (key absent) vs content
|
|
51
|
+
# present (even ""). This is the raw field already, kept as a named reader
|
|
52
|
+
# so both gates read intent, not a struct field, at the call site.
|
|
53
|
+
def content_or_nil
|
|
54
|
+
content
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Per-key tool_input/tool_params fallback (post-review fix, intent 244): old
|
|
59
|
+
# links-gate and create-gate read
|
|
60
|
+
# `payload.dig("tool_input", key) || payload.dig("tool_params", key)`, each
|
|
61
|
+
# key falling back independently, NOT the whole-object pick `input =
|
|
62
|
+
# tool_input || tool_params` used for gate_path/content/etc below. This
|
|
63
|
+
# matters for a payload shaped tool_input: {} plus
|
|
64
|
+
# tool_params: { file_path: X }: the whole-object read stops at the (empty)
|
|
65
|
+
# tool_input and never sees X; the per-key read still finds it. `prefer`
|
|
66
|
+
# flips which object wins when BOTH carry the key, since savepoint-pre alone
|
|
67
|
+
# read tool_params FIRST (see savepoint_path below).
|
|
68
|
+
def per_key_field(payload, key, prefer: "tool_input")
|
|
69
|
+
other = prefer == "tool_input" ? "tool_params" : "tool_input"
|
|
70
|
+
a = payload[prefer]
|
|
71
|
+
b = payload[other]
|
|
72
|
+
(a.is_a?(Hash) ? a[key] : nil) || (b.is_a?(Hash) ? b[key] : nil)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def context_from(payload, env: ENV)
|
|
76
|
+
input = payload["tool_input"] || payload["tool_params"] || {}
|
|
77
|
+
input = {} unless input.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
raw = input["file_path"] || input["notebook_path"] || input["relative_path"] || ""
|
|
80
|
+
if !raw.empty? && !raw.start_with?("/")
|
|
81
|
+
root = input["project_root"] || payload["cwd"] || ""
|
|
82
|
+
raw = File.join(root, raw) unless root.empty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
session = payload["session_id"]
|
|
86
|
+
session = env["CLAUDE_CODE_SESSION_ID"] if session.to_s.empty?
|
|
87
|
+
|
|
88
|
+
Context.new(
|
|
89
|
+
tool_name: payload["tool_name"],
|
|
90
|
+
session: (session unless session.to_s.empty?),
|
|
91
|
+
gate_path: raw,
|
|
92
|
+
file_path: per_key_field(payload, "file_path", prefer: "tool_input"),
|
|
93
|
+
# savepoint-pre's own historical precedence, inverted from links-gate /
|
|
94
|
+
# create-gate above: old hooks/savepoint-pre read
|
|
95
|
+
# `dig("tool_params","file_path") || dig("tool_input","file_path")`.
|
|
96
|
+
savepoint_path: per_key_field(payload, "file_path", prefer: "tool_params"),
|
|
97
|
+
content: input["content"],
|
|
98
|
+
old_string: input["old_string"],
|
|
99
|
+
new_string: input["new_string"],
|
|
100
|
+
replace_all: input["replace_all"],
|
|
101
|
+
harness: "claude",
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- savepoint-pre (intent 81): never denies; appends the `started` ledger line.
|
|
106
|
+
def savepoint_pre(ctx)
|
|
107
|
+
# ctx.savepoint_path carries context_from's tool_params-first read (fix 3);
|
|
108
|
+
# falls back to ctx.file_path for the thin CLI wrapper (hook-savepoint-pre),
|
|
109
|
+
# which only ever sets file_path directly and never populates savepoint_path.
|
|
110
|
+
path = ctx.savepoint_path || ctx.file_path
|
|
111
|
+
return ALLOW if path.nil? || path.empty?
|
|
112
|
+
|
|
113
|
+
abs = File.expand_path(path)
|
|
114
|
+
intent_dir = Bridge.intent_dir_for(abs)
|
|
115
|
+
return ALLOW unless intent_dir
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
Bridge.append_started_savepoint(intent_dir, abs)
|
|
119
|
+
rescue StandardError
|
|
120
|
+
# best-effort; the ledger is rebuildable and the post line still lands
|
|
121
|
+
end
|
|
122
|
+
ALLOW
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# --- lock-gate (intent 96, 111): fail-CLOSED delivery lock plus the artifact claim.
|
|
126
|
+
def lock_gate(ctx)
|
|
127
|
+
file_path = ctx.gate_path
|
|
128
|
+
return ALLOW if file_path.nil? || file_path.empty?
|
|
129
|
+
|
|
130
|
+
session = ctx.session
|
|
131
|
+
harness = ctx.harness
|
|
132
|
+
|
|
133
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
134
|
+
reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session, harness: harness)
|
|
135
|
+
|
|
136
|
+
unless reason
|
|
137
|
+
begin
|
|
138
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
139
|
+
Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
|
|
140
|
+
rescue StandardError
|
|
141
|
+
# ignore
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
begin
|
|
145
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
146
|
+
artifact = File.basename(file_path) if dir
|
|
147
|
+
if dir && artifact
|
|
148
|
+
claim_reason = Claim.claim_gate_reason(dir, artifact, session: session, harness: harness)
|
|
149
|
+
return deny_json(claim_reason) if claim_reason
|
|
150
|
+
|
|
151
|
+
if Claim.fail_open?(dir, artifact)
|
|
152
|
+
$stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
|
|
153
|
+
"intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
|
|
154
|
+
"proceeds (see /plastic-lock status)"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
Claim.heartbeat(dir, artifact, session: session) rescue nil
|
|
158
|
+
end
|
|
159
|
+
rescue StandardError
|
|
160
|
+
# ignore: a claim-gate bug must never block a write (fail open)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
return ALLOW
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
deny_json(reason)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def deny_json(reason)
|
|
170
|
+
Deny.new(shape: :json, stdout: JSON.generate(
|
|
171
|
+
"hookSpecificOutput" => {
|
|
172
|
+
"hookEventName" => "PreToolUse",
|
|
173
|
+
"permissionDecision" => "deny",
|
|
174
|
+
"permissionDecisionReason" => reason,
|
|
175
|
+
}
|
|
176
|
+
))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# --- code-gate (intents 27, 73c2, 150): stage rule OR worktree rule, first match wins.
|
|
180
|
+
def code_gate(ctx)
|
|
181
|
+
file_path = ctx.gate_path
|
|
182
|
+
return ALLOW if file_path.nil? || file_path.empty?
|
|
183
|
+
|
|
184
|
+
session = ctx.session
|
|
185
|
+
# Auditable escape (intent 150): mirrors the old Bash shim's independent
|
|
186
|
+
# ti["content"] || ti["new_string"] || "" computation for ARGV[2]. Kept
|
|
187
|
+
# local to code-gate; links-gate and create-gate never read it.
|
|
188
|
+
new_content = ctx.content || ctx.new_string || ""
|
|
189
|
+
|
|
190
|
+
if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
|
|
191
|
+
log_escape(session, file_path)
|
|
192
|
+
return ALLOW
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
|
|
196
|
+
return ALLOW unless bridge_data
|
|
197
|
+
|
|
198
|
+
reason = Bridge.code_gate_decision(bridge_data, file_path) ||
|
|
199
|
+
Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
|
|
200
|
+
return ALLOW unless reason
|
|
201
|
+
|
|
202
|
+
Deny.new(shape: :stderr, lines: ["PLASTIC GATE — #{reason}"])
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def log_escape(session, file_path)
|
|
206
|
+
require "fileutils"
|
|
207
|
+
log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
|
|
208
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
209
|
+
File.open(log, "a") do |io|
|
|
210
|
+
io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{file_path}")
|
|
211
|
+
end
|
|
212
|
+
rescue StandardError
|
|
213
|
+
# the escape still applies; logging is best-effort
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# --- block log (intent 229): one TSV line per block, six tab-separated fields
|
|
217
|
+
# in a fixed order. Mirrors log_escape above exactly: best-effort, wrapped in
|
|
218
|
+
# rescue StandardError, so a logging failure can never change what a gate
|
|
219
|
+
# decided and can never raise. `path` is the injected seam a test uses to
|
|
220
|
+
# force the write to fail without eval, ENV, or a global.
|
|
221
|
+
def block_log_path
|
|
222
|
+
File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def log_block(gate:, session:, intent:, subject:, rule:, path: nil)
|
|
226
|
+
require "fileutils"
|
|
227
|
+
log = path || block_log_path
|
|
228
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
229
|
+
File.open(log, "a") do |io|
|
|
230
|
+
io.puts([Time.now.utc.iso8601, gate, session.to_s, intent.to_s,
|
|
231
|
+
collapse_ws(subject), collapse_ws(rule)].join("\t"))
|
|
232
|
+
end
|
|
233
|
+
rescue StandardError
|
|
234
|
+
# the block still applies; logging is best-effort
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def collapse_ws(value)
|
|
238
|
+
value.to_s.gsub(/\s+/, " ").strip
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The reason text a Deny carries, per shape. Rescues to "" so a malformed
|
|
242
|
+
# outcome costs one field, never the line and never the decision.
|
|
243
|
+
def deny_reason(outcome)
|
|
244
|
+
if outcome.shape == :json
|
|
245
|
+
JSON.parse(outcome.stdout.to_s).dig("hookSpecificOutput", "permissionDecisionReason").to_s
|
|
246
|
+
else
|
|
247
|
+
Array(outcome.lines).join(" ")
|
|
248
|
+
end
|
|
249
|
+
rescue StandardError
|
|
250
|
+
""
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# "" when the path is not inside an intent dir, keeping the column count fixed.
|
|
254
|
+
def block_intent_id(path)
|
|
255
|
+
dir = Bridge.intent_dir_for(path.to_s)
|
|
256
|
+
dir ? Bridge.intent_id_from_dir(dir).to_s : ""
|
|
257
|
+
rescue StandardError
|
|
258
|
+
""
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# --- links-gate (intent 192): the write-time belt for the ## Links contract.
|
|
262
|
+
def links_gate(ctx)
|
|
263
|
+
path = ctx.file_path
|
|
264
|
+
return ALLOW if path.to_s.strip.empty?
|
|
265
|
+
|
|
266
|
+
abs = File.expand_path(path)
|
|
267
|
+
return ALLOW unless LinksGate.intent_file?(abs)
|
|
268
|
+
|
|
269
|
+
content = ctx.content_or_nil
|
|
270
|
+
old_string = ctx.old_string
|
|
271
|
+
|
|
272
|
+
before_content = File.exist?(abs) ? File.read(abs) : ""
|
|
273
|
+
|
|
274
|
+
after_content =
|
|
275
|
+
if !content.nil?
|
|
276
|
+
content
|
|
277
|
+
elsif !old_string.nil?
|
|
278
|
+
return ALLOW unless before_content.include?(old_string)
|
|
279
|
+
ctx.replace_all ? before_content.gsub(old_string, ctx.new_string.to_s)
|
|
280
|
+
: before_content.sub(old_string, ctx.new_string.to_s)
|
|
281
|
+
else
|
|
282
|
+
return ALLOW # pathless mutation (no visible proposal); cannot judge, allow
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
286
|
+
|
|
287
|
+
reason = LinksGate.decision(file_path: abs, before_content: before_content,
|
|
288
|
+
after_content: after_content, plastic_home: plastic_home)
|
|
289
|
+
return ALLOW unless reason
|
|
290
|
+
|
|
291
|
+
Deny.new(shape: :stderr, lines: [reason])
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# --- create-gate (intents 60b, 108 D7): validate the PROPOSED intent file content.
|
|
295
|
+
def create_gate(ctx)
|
|
296
|
+
path = ctx.file_path
|
|
297
|
+
return ALLOW if path.nil? || path.to_s.strip.empty?
|
|
298
|
+
|
|
299
|
+
abs = File.expand_path(path)
|
|
300
|
+
dir = File.dirname(abs)
|
|
301
|
+
is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
302
|
+
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
303
|
+
return ALLOW unless is_intent_file
|
|
304
|
+
|
|
305
|
+
content = ctx.content_or_nil
|
|
306
|
+
old_string = ctx.old_string
|
|
307
|
+
|
|
308
|
+
result =
|
|
309
|
+
if !content.nil?
|
|
310
|
+
IntentValidator.validate_content(content)
|
|
311
|
+
elsif !old_string.nil?
|
|
312
|
+
unless File.exist?(abs)
|
|
313
|
+
return Deny.new(shape: :stderr, lines: [
|
|
314
|
+
"PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
|
|
315
|
+
"create intents via new-intent / plastic-intent-creating.",
|
|
316
|
+
])
|
|
317
|
+
end
|
|
318
|
+
current = File.read(abs)
|
|
319
|
+
return ALLOW unless current.include?(old_string)
|
|
320
|
+
simulated = ctx.replace_all ? current.gsub(old_string, ctx.new_string.to_s)
|
|
321
|
+
: current.sub(old_string, ctx.new_string.to_s)
|
|
322
|
+
IntentValidator.validate_content(simulated)
|
|
323
|
+
else
|
|
324
|
+
unless File.exist?(abs)
|
|
325
|
+
return Deny.new(shape: :stderr, lines: [
|
|
326
|
+
"PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
|
|
327
|
+
"content and no file exists; refusing to allow an unvalidated intent write.",
|
|
328
|
+
])
|
|
329
|
+
end
|
|
330
|
+
IntentValidator.validate_content(File.read(abs))
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
return ALLOW if result[:ok]
|
|
334
|
+
|
|
335
|
+
lines = ["PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"]
|
|
336
|
+
result[:errors].each { |e| lines << " #{e}" }
|
|
337
|
+
lines << "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
|
|
338
|
+
Deny.new(shape: :stderr, lines: lines)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Applicability (post-review fixes 1+2, intent 244): reproduces the Claude
|
|
342
|
+
# harness's OWN matcher semantics, not the dispatcher's earlier string
|
|
343
|
+
# equality. Two corrections:
|
|
344
|
+
# - the harness matches an UNANCHORED REGEX (`"Write|Edit"` matches
|
|
345
|
+
# "NotebookEdit" by substring), so applicability must too, or a gate
|
|
346
|
+
# whose table entry never listed NotebookEdit explicitly stops firing on
|
|
347
|
+
# it even though the old per-gate matcher group DID fire (a coverage
|
|
348
|
+
# narrowing the byte-identical-behavior bar exists to catch);
|
|
349
|
+
# - a missing/unrecognized tool_name means "the harness matched, but did
|
|
350
|
+
# not name the tool", not "not our tool": the five old wrappers never
|
|
351
|
+
# read tool_name at all, so they ALL ran on such a payload. Skipping
|
|
352
|
+
# every gate here would be a coverage WEAKENING (the gate-weakening
|
|
353
|
+
# direction is the dangerous one; see intent 203).
|
|
354
|
+
def tool_applies?(tool_name, tools)
|
|
355
|
+
return true if tool_name.to_s.empty?
|
|
356
|
+
Regexp.new(tools.join("|")).match?(tool_name.to_s)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# --- the ordered evaluation (spec D-a, D-i) -------------------------------
|
|
360
|
+
# `route` is injected so a test can supply a raising gate and prove isolation
|
|
361
|
+
# without eval, an ENV seam, or a global. `gate_tools` is injected for the same
|
|
362
|
+
# reason and defaults to the single source of truth.
|
|
363
|
+
# Returns the process exit code.
|
|
364
|
+
def dispatch(ctx:, route:, gate_tools: HookRegistry::GATE_TOOLS, out: $stdout, err: $stderr,
|
|
365
|
+
block_log: nil)
|
|
366
|
+
gate_tools.each do |gate, tools|
|
|
367
|
+
next unless tool_applies?(ctx.tool_name, tools)
|
|
368
|
+
|
|
369
|
+
begin
|
|
370
|
+
outcome = route.call(gate, ctx)
|
|
371
|
+
next unless outcome
|
|
372
|
+
|
|
373
|
+
# intent 229: one block-log line per deny, both shapes, one call site.
|
|
374
|
+
# Every helper here rescues internally, so nothing new can reach this
|
|
375
|
+
# method's rescue and turn a logging problem into a changed decision.
|
|
376
|
+
subject = ctx.gate_path.to_s.empty? ? ctx.file_path.to_s : ctx.gate_path.to_s
|
|
377
|
+
log_block(gate: gate, session: ctx.session, intent: block_intent_id(subject),
|
|
378
|
+
subject: subject, rule: deny_reason(outcome), path: block_log)
|
|
379
|
+
|
|
380
|
+
if outcome.shape == :json
|
|
381
|
+
out.print outcome.stdout
|
|
382
|
+
return 0
|
|
383
|
+
else
|
|
384
|
+
outcome.lines.each { |line| err.puts line }
|
|
385
|
+
return 2
|
|
386
|
+
end
|
|
387
|
+
rescue StandardError => e
|
|
388
|
+
# Fix 7: outcome.shape / out.print / err.puts now live INSIDE the same
|
|
389
|
+
# rescue as route.call, so a malformed outcome or a stream write
|
|
390
|
+
# failure (e.g. EPIPE) is isolated exactly like a raising gate, and
|
|
391
|
+
# never aborts the gates still to come.
|
|
392
|
+
err.puts "plastic #{gate} error: #{e.message}"
|
|
393
|
+
next
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
0
|
|
397
|
+
end
|
|
398
|
+
end
|