@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,103 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# exec-worktree (intent 213, group 2) - finish an intent's code worktree: an order
|
|
6
|
+
# precondition, then Worktree.finish (delivered merges + removes, abandoned removes only).
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# exec-worktree --store <path> --id <intent_id> --home <plastic_home> \
|
|
10
|
+
# --disposition delivered|abandoned [--session <session_id>]
|
|
11
|
+
#
|
|
12
|
+
# --home accepts either spelling: the OS-home level (the PARENT of .plastic) or the
|
|
13
|
+
# .plastic directory itself. When the given path's basename is literally .plastic, its
|
|
14
|
+
# dirname is used instead. Worktree.finish's home: keyword always wants the OS-home
|
|
15
|
+
# level (Worktree.home_from_store, scripts/lib/worktree.rb:90); this script normalizes
|
|
16
|
+
# both spellings to that so either caller convention works.
|
|
17
|
+
#
|
|
18
|
+
# Steps performed:
|
|
19
|
+
# 0. Resolve inputs: the intent dir, normalize --home, resolve the calling session
|
|
20
|
+
# (--session, else CLAUDE_CODE_SESSION_ID, else the existing delivery.lock's own
|
|
21
|
+
# recorded owner_session when non-blank, else nil; unlike start-intent, the
|
|
22
|
+
# lock-owner fallback IS used here, matching end-intent's teardown resolution order).
|
|
23
|
+
# Load the bridge (Bridge.read); a nil bridge means there is nothing to finish (exit
|
|
24
|
+
# 0, loud: the worktree this intent provisioned, if any, was NOT removed). No
|
|
25
|
+
# worktree.code on the bridge means nothing was provisioned (exit 0).
|
|
26
|
+
# 1. The order precondition (delivered only): calls the EXISTING
|
|
27
|
+
# Bridge.code_gate_decision predicate exactly as it exists. On an auto bridge that
|
|
28
|
+
# has not reached How, this BLOCKS (exit 2). Bridge.code_gate_decision returns nil
|
|
29
|
+
# UNCONDITIONALLY on a guided bridge (build.auto != true), so on a guided bridge
|
|
30
|
+
# this precondition is ADVISORY ONLY: it always passes and the run proceeds. This
|
|
31
|
+
# script is NOT the enforcement point either way; the hook layer
|
|
32
|
+
# (scripts/lib/bridge.rb code_gate_decision, wired through scripts/lib/edit_gates.rb)
|
|
33
|
+
# remains the actual gate. See scripts/lib/exec_worktree.rb's module doc for why the
|
|
34
|
+
# auto-only condition is never relaxed and never duplicated here.
|
|
35
|
+
# 2. The dirty-worktree guard (delivered only): git status --porcelain on the code
|
|
36
|
+
# worktree. Not clean, or the check itself fails, fails CLOSED (exit 3), mirroring
|
|
37
|
+
# end-intent's dirty-worktree handling. abandoned skips this guard entirely: the
|
|
38
|
+
# branch survives and can be reclaimed, so a dirty worktree is not a reason to
|
|
39
|
+
# refuse.
|
|
40
|
+
# 3. Finish: Worktree.finish(bridge_data, home:, runner:, merge: disposition ==
|
|
41
|
+
# "delivered"). Worktree.finish is fail-open and never raises, so failure is
|
|
42
|
+
# detected by checking, for a delivered disposition only, whether the worktree
|
|
43
|
+
# directory still exists on disk afterward (release removes it on success).
|
|
44
|
+
#
|
|
45
|
+
# No test suite is run by this script, in any form. verify-intent owns test execution;
|
|
46
|
+
# one concern per script (spec D8).
|
|
47
|
+
#
|
|
48
|
+
# Exit codes:
|
|
49
|
+
# 0 finished (merged or removed per disposition), or nothing to finish (no bridge
|
|
50
|
+
# resolved, or no code worktree recorded on the bridge)
|
|
51
|
+
# 1 usage or path-resolution failure
|
|
52
|
+
# 2 the order precondition blocked a delivered disposition (auto bridge, How not
|
|
53
|
+
# reached)
|
|
54
|
+
# 3 the worktree is not clean, or the merge/removal did not complete (delivered only)
|
|
55
|
+
# 4 lock or session resolution failure
|
|
56
|
+
|
|
57
|
+
require_relative "lib/exec_worktree"
|
|
58
|
+
|
|
59
|
+
def parse_args(argv)
|
|
60
|
+
opts = { store: nil, id: nil, home: nil, disposition: nil, session: nil }
|
|
61
|
+
i = 0
|
|
62
|
+
while i < argv.length
|
|
63
|
+
arg = argv[i]
|
|
64
|
+
case arg
|
|
65
|
+
when "--store" then opts[:store] = argv[i += 1]
|
|
66
|
+
when "--id" then opts[:id] = argv[i += 1]
|
|
67
|
+
when "--home" then opts[:home] = argv[i += 1]
|
|
68
|
+
when "--disposition" then opts[:disposition] = argv[i += 1]
|
|
69
|
+
when "--session" then opts[:session] = argv[i += 1]
|
|
70
|
+
else
|
|
71
|
+
usage_abort("unknown argument #{arg.inspect}")
|
|
72
|
+
end
|
|
73
|
+
i += 1
|
|
74
|
+
end
|
|
75
|
+
opts
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def usage
|
|
79
|
+
"usage: exec-worktree --store <store_path> --id <intent_id> --home <plastic_home> " \
|
|
80
|
+
"--disposition delivered|abandoned [--session <session_id>]"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def usage_abort(message)
|
|
84
|
+
warn "exec-worktree: #{message}"
|
|
85
|
+
warn usage
|
|
86
|
+
exit 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def main(argv)
|
|
90
|
+
opts = parse_args(argv)
|
|
91
|
+
|
|
92
|
+
result = ExecWorktree.run(
|
|
93
|
+
store: opts[:store], id: opts[:id], home: opts[:home], disposition: opts[:disposition],
|
|
94
|
+
session: opts[:session], env_session: ENV["CLAUDE_CODE_SESSION_ID"]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
Array(result[:stderr]).each { |line| warn line }
|
|
98
|
+
warn usage if result[:exit_code] == ExecWorktree::EXIT_USAGE
|
|
99
|
+
Array(result[:stdout]).each { |line| puts line }
|
|
100
|
+
exit result[:exit_code]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
main(ARGV) if $PROGRAM_NAME == __FILE__
|
package/scripts/hash-intent
CHANGED
|
@@ -13,7 +13,7 @@ fi
|
|
|
13
13
|
|
|
14
14
|
# Try Ruby first (macOS/Linux default)
|
|
15
15
|
if command -v ruby &>/dev/null; then
|
|
16
|
-
ruby -r digest -e 'puts Digest::SHA256.hexdigest(ARGV[0]).to_i(16).to_s(36)[0,6]' "$INTENT_NAME"
|
|
16
|
+
env -u RUBYOPT ruby -r digest -e 'puts Digest::SHA256.hexdigest(ARGV[0]).to_i(16).to_s(36)[0,6]' "$INTENT_NAME"
|
|
17
17
|
exit 0
|
|
18
18
|
fi
|
|
19
19
|
|
package/scripts/hook-bash-gate
CHANGED
|
@@ -55,5 +55,24 @@ bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
|
|
|
55
55
|
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd, session: session)
|
|
56
56
|
exit 0 unless reason
|
|
57
57
|
|
|
58
|
+
# Block log (intent 229): one six-field TSV line, hand-rolled here exactly as
|
|
59
|
+
# the escape write above is, so this script keeps requiring only lib/bridge and
|
|
60
|
+
# pays no extra load on every Bash tool call. Best-effort: the block applies
|
|
61
|
+
# regardless.
|
|
62
|
+
begin
|
|
63
|
+
require "fileutils"
|
|
64
|
+
require "time"
|
|
65
|
+
blocklog = File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
|
|
66
|
+
FileUtils.mkdir_p(File.dirname(blocklog))
|
|
67
|
+
intent_id = bridge_data.is_a?(Hash) ? bridge_data.dig("intent", "id").to_s : ""
|
|
68
|
+
File.open(blocklog, "a") do |io|
|
|
69
|
+
io.puts([Time.now.utc.iso8601, "bash-gate", session.to_s, intent_id,
|
|
70
|
+
command.to_s.gsub(/\s+/, " ").strip,
|
|
71
|
+
reason.to_s.gsub(/\s+/, " ").strip].join("\t"))
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError
|
|
74
|
+
# the block still applies; logging is best-effort
|
|
75
|
+
end
|
|
76
|
+
|
|
58
77
|
$stderr.puts "PLASTIC GATE — #{reason}"
|
|
59
78
|
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,27 @@
|
|
|
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) and, since intent 251, the merged Codex dispatcher
|
|
19
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
20
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
21
|
+
# hook test suite.
|
|
15
22
|
|
|
16
|
-
require_relative "lib/
|
|
23
|
+
require_relative "lib/edit_gates"
|
|
17
24
|
|
|
18
25
|
file_path = ARGV[0]
|
|
19
26
|
exit 0 unless file_path && !file_path.empty?
|
|
20
27
|
|
|
21
28
|
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
22
29
|
|
|
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
|
|
30
|
+
ctx = EditGates::Context.new(
|
|
31
|
+
tool_name: "Write", session: session, gate_path: file_path, file_path: file_path,
|
|
32
|
+
content: ARGV[2], harness: "claude",
|
|
33
|
+
)
|
|
48
34
|
|
|
49
|
-
|
|
35
|
+
outcome = EditGates.code_gate(ctx)
|
|
36
|
+
exit 0 unless outcome
|
|
37
|
+
outcome.lines.each { |line| $stderr.puts line }
|
|
50
38
|
exit 2
|
package/scripts/hook-continue
CHANGED
|
@@ -15,7 +15,7 @@ exit 0 unless index_path && File.exist?(index_path)
|
|
|
15
15
|
dashboard = File.expand_path("dashboard.rb", __dir__)
|
|
16
16
|
exit 0 unless File.exist?(dashboard)
|
|
17
17
|
|
|
18
|
-
cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
|
|
18
|
+
cockpit, _err, status = Open3.capture3({"RUBYOPT" => nil}, "ruby", dashboard, "continue")
|
|
19
19
|
exit 0 unless status.success? && !cockpit.strip.empty?
|
|
20
20
|
|
|
21
21
|
context = cockpit.rstrip +
|
|
@@ -36,7 +36,7 @@ payload = {
|
|
|
36
36
|
# degrades silently, omitting systemMessage; this hook must never crash
|
|
37
37
|
# UserPromptSubmit over a summary line.
|
|
38
38
|
begin
|
|
39
|
-
data_json, _data_err, data_status = Open3.capture3("ruby", dashboard, "continue", "--data")
|
|
39
|
+
data_json, _data_err, data_status = Open3.capture3({"RUBYOPT" => nil}, "ruby", dashboard, "continue", "--data")
|
|
40
40
|
if data_status.success?
|
|
41
41
|
banner = DashboardBanner.render(JSON.parse(data_json))
|
|
42
42
|
payload["systemMessage"] = banner if banner
|
package/scripts/hook-create-gate
CHANGED
|
@@ -26,9 +26,18 @@
|
|
|
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) and, since intent 251, the merged Codex dispatcher
|
|
33
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
34
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
35
|
+
# hook test suite. Deliberately WITHOUT a top-level rescue, matching this
|
|
36
|
+
# script's behavior before the extraction: an internal exception here has
|
|
37
|
+
# never been caught.
|
|
29
38
|
|
|
30
39
|
require "json"
|
|
31
|
-
require_relative "lib/
|
|
40
|
+
require_relative "lib/edit_gates"
|
|
32
41
|
|
|
33
42
|
raw = $stdin.read rescue nil
|
|
34
43
|
exit 0 if raw.nil? || raw.strip.empty?
|
|
@@ -36,60 +45,7 @@ exit 0 if raw.nil? || raw.strip.empty?
|
|
|
36
45
|
payload = JSON.parse(raw) rescue nil
|
|
37
46
|
exit 0 unless payload.is_a?(Hash)
|
|
38
47
|
|
|
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."
|
|
48
|
+
outcome = EditGates.create_gate(EditGates.context_from(payload))
|
|
49
|
+
exit 0 unless outcome
|
|
50
|
+
outcome.lines.each { |line| $stderr.puts line }
|
|
95
51
|
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-gate-check
CHANGED
|
@@ -99,6 +99,23 @@ if is_stage_file || is_action_file
|
|
|
99
99
|
error = Bridge.check_gate(bridge_intent_dir_abs, file_path_abs)
|
|
100
100
|
|
|
101
101
|
if error
|
|
102
|
+
# Block log (intent 229): same hand-rolled best-effort write as
|
|
103
|
+
# hook-bash-gate's; this script keeps its current requires.
|
|
104
|
+
begin
|
|
105
|
+
require "fileutils"
|
|
106
|
+
require "time"
|
|
107
|
+
blocklog = File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
|
|
108
|
+
FileUtils.mkdir_p(File.dirname(blocklog))
|
|
109
|
+
File.open(blocklog, "a") do |io|
|
|
110
|
+
io.puts([Time.now.utc.iso8601, "gate-check", session.to_s,
|
|
111
|
+
Bridge.intent_id_from_dir(bridge_intent_dir_abs).to_s,
|
|
112
|
+
file_path_abs.to_s.gsub(/\s+/, " ").strip,
|
|
113
|
+
error.to_s.gsub(/\s+/, " ").strip].join("\t"))
|
|
114
|
+
end
|
|
115
|
+
rescue StandardError
|
|
116
|
+
# the block still applies; logging is best-effort
|
|
117
|
+
end
|
|
118
|
+
|
|
102
119
|
# Gate violation — block the write
|
|
103
120
|
bridge_data["build"]["gate_failures"] = (bridge_data["build"]["gate_failures"] || 0) + 1
|
|
104
121
|
Bridge.write(session, bridge_data)
|
package/scripts/hook-links-gate
CHANGED
|
@@ -13,9 +13,16 @@
|
|
|
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) and, since intent 251, the merged Codex dispatcher
|
|
20
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
21
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
22
|
+
# hook test suite.
|
|
16
23
|
|
|
17
24
|
require "json"
|
|
18
|
-
require_relative "lib/
|
|
25
|
+
require_relative "lib/edit_gates"
|
|
19
26
|
|
|
20
27
|
begin
|
|
21
28
|
raw = begin
|
|
@@ -32,36 +39,9 @@ begin
|
|
|
32
39
|
end
|
|
33
40
|
exit 0 unless payload.is_a?(Hash)
|
|
34
41
|
|
|
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
|
|
42
|
+
outcome = EditGates.links_gate(EditGates.context_from(payload))
|
|
43
|
+
exit 0 unless outcome
|
|
44
|
+
outcome.lines.each { |line| $stderr.puts line }
|
|
65
45
|
exit 2
|
|
66
46
|
rescue => e
|
|
67
47
|
# Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
|
package/scripts/hook-lock-gate
CHANGED
|
@@ -8,9 +8,15 @@
|
|
|
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) and, since intent 251, the merged Codex dispatcher
|
|
15
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
16
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
17
|
+
# hook test suite.
|
|
18
|
+
|
|
19
|
+
require_relative "lib/edit_gates"
|
|
14
20
|
|
|
15
21
|
begin
|
|
16
22
|
file_path = ARGV[0]
|
|
@@ -18,66 +24,19 @@ begin
|
|
|
18
24
|
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
19
25
|
# Usage: hook-lock-gate <file_path> [session_id] [harness]. Claude's own bash
|
|
20
26
|
# shim never passes a third arg, so harness falls through to Lock's default
|
|
21
|
-
# (claude, slash form)
|
|
22
|
-
# "codex"
|
|
27
|
+
# (claude, slash form). Since intent 251 the merged Codex library
|
|
28
|
+
# (scripts/lib/codex_edit_gates.rb) sets harness: "codex" directly on the
|
|
29
|
+
# Context it builds; no caller of this wrapper passes "codex" anymore.
|
|
23
30
|
harness = (ARGV[2] unless ARGV[2].to_s.empty?) || "claude"
|
|
24
31
|
|
|
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
|
-
}
|
|
32
|
+
ctx = EditGates::Context.new(
|
|
33
|
+
tool_name: "Write", session: session, gate_path: file_path, file_path: file_path,
|
|
34
|
+
harness: harness,
|
|
80
35
|
)
|
|
36
|
+
|
|
37
|
+
outcome = EditGates.lock_gate(ctx)
|
|
38
|
+
exit 0 unless outcome
|
|
39
|
+
print outcome.stdout
|
|
81
40
|
exit 0
|
|
82
41
|
rescue => e
|
|
83
42
|
# 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,20 @@
|
|
|
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) and, since intent 251, the merged Codex dispatcher
|
|
20
|
+
# (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
|
|
21
|
+
# either harness anymore; it remains as the per-gate isolation surface for the
|
|
22
|
+
# hook test suite.
|
|
16
23
|
|
|
17
|
-
require_relative "lib/
|
|
24
|
+
require_relative "lib/edit_gates"
|
|
18
25
|
|
|
19
26
|
file_path = ARGV[0]
|
|
20
27
|
exit 0 if file_path.nil? || file_path.empty?
|
|
21
28
|
|
|
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
|
-
|
|
29
|
+
EditGates.savepoint_pre(
|
|
30
|
+
EditGates::Context.new(tool_name: "Write", file_path: file_path, harness: "claude")
|
|
31
|
+
)
|
|
32
32
|
exit 0
|