@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.
Files changed (72) hide show
  1. package/PLASTIC.md +75 -535
  2. package/README.md +3 -2
  3. package/agents/plastic-intent-curator.md +2 -2
  4. package/deprecations.yml +10 -2
  5. package/hooks/{links-gate → edit-gates} +1 -1
  6. package/hooks/hooks.json +4 -44
  7. package/hooks/power-tools +8 -0
  8. package/package.json +1 -2
  9. package/scripts/codex-hook +17 -22
  10. package/scripts/doctor.rb +688 -186
  11. package/scripts/end-intent +65 -22
  12. package/scripts/hook-code-gate +13 -28
  13. package/scripts/hook-create-gate +10 -57
  14. package/scripts/hook-edit-gates +58 -0
  15. package/scripts/hook-links-gate +8 -31
  16. package/scripts/hook-lock-gate +13 -58
  17. package/scripts/hook-power-tools +38 -0
  18. package/scripts/hook-savepoint-pre +8 -11
  19. package/scripts/lib/bridge.rb +2 -2
  20. package/scripts/lib/edit_gates.rb +342 -0
  21. package/scripts/lib/hook_registry.rb +61 -29
  22. package/scripts/lib/installer_core.rb +100 -18
  23. package/scripts/lib/outcome_guard.rb +38 -0
  24. package/scripts/lib/qmd_hook.rb +21 -41
  25. package/scripts/lib/qmd_sync.rb +0 -15
  26. package/scripts/lib/revisions_writer.rb +1 -1
  27. package/scripts/maintenance-run +56 -2
  28. package/scripts/restore-intent-v1 +16 -11
  29. package/skills/auto/SKILL.md +15 -4
  30. package/skills/auto/evals/evals.json +2 -2
  31. package/skills/conventions/SKILL.md +31 -0
  32. package/skills/conventions/references/completion-and-done.md +40 -0
  33. package/skills/conventions/references/gates-and-enforcement.md +23 -0
  34. package/skills/conventions/references/knowledge-graph.md +47 -0
  35. package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
  36. package/skills/conventions/references/locks-and-worktrees.md +113 -0
  37. package/skills/conventions/references/maintenance-and-revisions.md +170 -0
  38. package/skills/conventions/references/roadmaps.md +44 -0
  39. package/skills/conventions/references/tiers-and-dispatch.md +134 -0
  40. package/skills/doctor/SKILL.md +59 -19
  41. package/skills/intent-brainstorming/SKILL.md +4 -0
  42. package/skills/intent-continuing/SKILL.md +4 -0
  43. package/skills/intent-creating/SKILL.md +7 -2
  44. package/skills/intent-ending/SKILL.md +20 -6
  45. package/skills/intent-executing/SKILL.md +10 -0
  46. package/skills/intent-linking/SKILL.md +4 -0
  47. package/skills/intent-locking/SKILL.md +4 -0
  48. package/skills/intent-planning/SKILL.md +7 -0
  49. package/skills/intent-savepoint/SKILL.md +4 -0
  50. package/skills/intent-speccing/SKILL.md +4 -0
  51. package/skills/intent-starting/SKILL.md +10 -0
  52. package/skills/project-creating/references/project-scaffolding.md +2 -2
  53. package/skills/roadmap/SKILL.md +4 -0
  54. package/skills/roadmap-continuing/SKILL.md +4 -0
  55. package/skills/skill-creating/SKILL.md +3 -0
  56. package/skills/skill-creating/references/defaults-first.md +23 -0
  57. package/skills/skill-creating/references/hooks.md +4 -1
  58. package/skills/store-curating/SKILL.md +8 -0
  59. package/skills/store-indexing/SKILL.md +8 -0
  60. package/skills/tutorial/references/track-2-auto.md +2 -3
  61. package/templates/agents.md +8 -0
  62. package/PLASTIC-reference.md +0 -138
  63. package/hooks/code-gate +0 -27
  64. package/hooks/create-gate +0 -3
  65. package/hooks/lock-gate +0 -21
  66. package/hooks/qmd-search +0 -8
  67. package/hooks/retrieval-gate +0 -10
  68. package/hooks/savepoint-pre +0 -10
  69. package/scripts/hook-qmd-search +0 -44
  70. package/scripts/hook-retrieval-gate +0 -148
  71. package/scripts/lib/legacy_bookend_amnesty.rb +0 -35
  72. package/scripts/lib/retrieval_gate.rb +0 -211
@@ -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
- # --- outcome.md guard (D2 step 1a) ------------------------------------------
151
+ # --- structure gate helper (intent 222) -------------------------------------
147
152
  #
148
- # Returns nil when the guard PASSES, or a reason String to refuse with exit 2.
149
- # Read-only: authors nothing regardless of outcome. Refuses a missing file, a
150
- # still-placeholder file (first line is the sentinel), or a disposition that
151
- # does not exactly equal the requested one (this is what naturally refuses a
152
- # freshly scaffolded outcome.md, whose disposition is the literal
153
- # "delivered|abandoned" string, not one of the two real values).
154
- def outcome_guard_reason(intent_dir, disposition)
155
- path = File.join(intent_dir, "outcome.md")
156
- return "outcome.md is missing at #{path}" unless File.exist?(path)
157
-
158
- content = File.read(path)
159
- first_line = content.each_line.first.to_s.chomp
160
- if first_line == Bridge::PLACEHOLDER_SENTINEL
161
- return "outcome.md is still the scaffold placeholder (first line is the sentinel)"
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
- fm = IntentValidator.parse_frontmatter_text(content)
165
- actual = fm.is_a?(Hash) ? fm["disposition"] : nil
166
- unless DISPOSITIONS.include?(actual) && actual == disposition
167
- return "outcome.md frontmatter disposition is #{actual.inspect}, expected #{disposition.inspect}"
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
- nil
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
- reason = outcome_guard_reason(intent_dir, disposition)
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
@@ -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/bridge"
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
- # Auditable escape (intent 150, mirrors the Bash gate's intent 108 D7): a trailing
24
- # `# plastic-ok` in the new Write/Edit content allows the edit and logs it, so a
25
- # deliberately sanctioned code edit no longer needs the Bash-funneling workaround.
26
- new_content = ARGV[2]
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
- $stderr.puts "PLASTIC GATE — #{reason}"
32
+ outcome = EditGates.code_gate(ctx)
33
+ exit 0 unless outcome
34
+ outcome.lines.each { |line| $stderr.puts line }
50
35
  exit 2
@@ -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/intent_validator"
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
- path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
40
- exit 0 if path.nil? || path.to_s.strip.empty?
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
@@ -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/links_gate"
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
- path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
36
- exit 0 if path.to_s.strip.empty?
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
@@ -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
- require "json"
12
- require_relative "lib/bridge"
13
- require_relative "lib/lock"
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
- bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
26
- reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session, harness: harness)
27
- unless reason
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/bridge"
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
- file_path_abs = File.expand_path(file_path)
23
- intent_dir = Bridge.intent_dir_for(file_path_abs)
24
- exit 0 unless intent_dir
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
@@ -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 (mirrors the retrieval gate's
1602
- # `# qmd-ok`). The hook logs every use to ~/.plastic/.cache/gate-escapes.log.
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)