@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.
Files changed (127) hide show
  1. package/PLASTIC.md +94 -556
  2. package/README.md +18 -6
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-curator.md +2 -2
  5. package/agents/plastic-intent-discovery.md +7 -0
  6. package/bin/plastic.js +17 -8
  7. package/deprecations.yml +10 -2
  8. package/hooks/auto-arm +2 -2
  9. package/hooks/bash-gate +1 -1
  10. package/hooks/check-update +1 -1
  11. package/hooks/continue +2 -2
  12. package/hooks/edit-gates +3 -0
  13. package/hooks/future-intent-check +2 -2
  14. package/hooks/gate-check +3 -3
  15. package/hooks/hooks.json +4 -44
  16. package/hooks/power-tools +8 -0
  17. package/hooks/session-start +1 -1
  18. package/package.json +1 -2
  19. package/scripts/codex-hook +57 -118
  20. package/scripts/doctor.rb +146 -1012
  21. package/scripts/exec-worktree +103 -0
  22. package/scripts/hash-intent +1 -1
  23. package/scripts/hook-bash-gate +19 -0
  24. package/scripts/hook-code-gate +16 -28
  25. package/scripts/hook-continue +2 -2
  26. package/scripts/hook-create-gate +13 -57
  27. package/scripts/hook-edit-gates +58 -0
  28. package/scripts/hook-gate-check +17 -0
  29. package/scripts/hook-links-gate +11 -31
  30. package/scripts/hook-lock-gate +19 -60
  31. package/scripts/hook-power-tools +38 -0
  32. package/scripts/hook-savepoint-pre +11 -11
  33. package/scripts/hook-session-start +21 -15
  34. package/scripts/lib/apply_patch_envelope.rb +46 -13
  35. package/scripts/lib/bridge.rb +85 -17
  36. package/scripts/lib/codex_edit_gates.rb +138 -0
  37. package/scripts/lib/doctor_core.rb +1087 -0
  38. package/scripts/lib/edit_gates.rb +398 -0
  39. package/scripts/lib/exec_worktree.rb +325 -0
  40. package/scripts/lib/harness_text.rb +57 -0
  41. package/scripts/lib/hook_registry.rb +64 -28
  42. package/scripts/lib/installer_core.rb +166 -24
  43. package/scripts/lib/lock.rb +196 -47
  44. package/scripts/lib/qmd_hook.rb +21 -41
  45. package/scripts/lib/qmd_sync.rb +0 -15
  46. package/scripts/lib/revisions_writer.rb +1 -1
  47. package/scripts/lib/ruby_probe.rb +60 -0
  48. package/scripts/lib/scaffold_intent.rb +392 -0
  49. package/scripts/lib/spec_header.rb +83 -0
  50. package/scripts/lib/start_intent.rb +296 -0
  51. package/scripts/lib/verify_intent.rb +262 -0
  52. package/scripts/lib/worktree.rb +15 -1
  53. package/scripts/link-suggest +1 -1
  54. package/scripts/maintenance-run +5 -5
  55. package/scripts/migrate-to-global +2 -2
  56. package/scripts/restore-intent-v1 +17 -12
  57. package/scripts/scaffold-intent +120 -0
  58. package/scripts/start-intent +89 -0
  59. package/scripts/verify-intent +73 -0
  60. package/skills/agent-advisor/SKILL.md +5 -5
  61. package/skills/auto/SKILL.md +52 -32
  62. package/skills/auto/evals/evals.json +2 -2
  63. package/skills/auto/references/agent-architecture.md +1 -1
  64. package/skills/auto/references/agent-report-contract.md +1 -1
  65. package/skills/auto/references/human-report-contract.md +22 -3
  66. package/skills/auto/references/tiers.md +24 -2
  67. package/skills/conventions/SKILL.md +31 -0
  68. package/skills/conventions/references/completion-and-done.md +43 -0
  69. package/skills/conventions/references/gates-and-enforcement.md +39 -0
  70. package/skills/conventions/references/knowledge-graph.md +47 -0
  71. package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
  72. package/skills/conventions/references/locks-and-worktrees.md +113 -0
  73. package/skills/conventions/references/maintenance-and-revisions.md +170 -0
  74. package/skills/conventions/references/roadmaps.md +44 -0
  75. package/skills/conventions/references/tiers-and-dispatch.md +135 -0
  76. package/skills/dashboard/SKILL.md +1 -1
  77. package/skills/doctor/SKILL.md +15 -6
  78. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  79. package/skills/doctor/report.md +1 -1
  80. package/skills/install/SKILL.md +1 -1
  81. package/skills/intent-brainstorming/SKILL.md +4 -2
  82. package/skills/intent-continuing/SKILL.md +4 -0
  83. package/skills/intent-creating/SKILL.md +13 -8
  84. package/skills/intent-creating/references/lifecycle.md +1 -1
  85. package/skills/intent-discovering/SKILL.md +10 -3
  86. package/skills/intent-ending/SKILL.md +12 -7
  87. package/skills/intent-executing/SKILL.md +37 -19
  88. package/skills/intent-grilling/SKILL.md +5 -3
  89. package/skills/intent-linking/SKILL.md +4 -0
  90. package/skills/intent-locking/SKILL.md +4 -0
  91. package/skills/intent-planning/SKILL.md +14 -3
  92. package/skills/intent-researching/SKILL.md +0 -2
  93. package/skills/intent-savepoint/SKILL.md +4 -0
  94. package/skills/intent-speccing/SKILL.md +4 -0
  95. package/skills/intent-starting/SKILL.md +20 -2
  96. package/skills/project-creating/SKILL.md +0 -2
  97. package/skills/project-creating/references/project-scaffolding.md +3 -3
  98. package/skills/releasing/SKILL.md +1 -1
  99. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +4 -0
  102. package/skills/roadmap-continuing/SKILL.md +4 -0
  103. package/skills/skill-creating/SKILL.md +8 -2
  104. package/skills/skill-creating/references/defaults-first.md +23 -0
  105. package/skills/skill-creating/references/hooks.md +4 -1
  106. package/skills/store-curating/SKILL.md +8 -0
  107. package/skills/store-indexing/SKILL.md +16 -5
  108. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  109. package/skills/tutorial/references/track-1-guided.md +2 -2
  110. package/skills/tutorial/references/track-2-auto.md +10 -8
  111. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  112. package/skills/uninstall/SKILL.md +6 -9
  113. package/templates/agents.md +12 -12
  114. package/templates/config.yml +6 -7
  115. package/templates/index.md +6 -3
  116. package/templates/spec.md +1 -1
  117. package/PLASTIC-reference.md +0 -138
  118. package/hooks/code-gate +0 -27
  119. package/hooks/create-gate +0 -3
  120. package/hooks/links-gate +0 -3
  121. package/hooks/lock-gate +0 -21
  122. package/hooks/qmd-search +0 -8
  123. package/hooks/retrieval-gate +0 -10
  124. package/hooks/savepoint-pre +0 -10
  125. package/scripts/hook-qmd-search +0 -44
  126. package/scripts/hook-retrieval-gate +0 -148
  127. 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__
@@ -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
 
@@ -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
@@ -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/bridge"
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
- # 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
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
- $stderr.puts "PLASTIC GATE — #{reason}"
35
+ outcome = EditGates.code_gate(ctx)
36
+ exit 0 unless outcome
37
+ outcome.lines.each { |line| $stderr.puts line }
50
38
  exit 2
@@ -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
@@ -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/intent_validator"
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
- 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."
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
@@ -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)
@@ -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/links_gate"
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
- 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
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
@@ -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
- 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) 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); scripts/codex-hook is the only caller that passes
22
- # "codex" here (intent 201 D2).
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
- 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
- }
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/bridge"
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
- 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
-
29
+ EditGates.savepoint_pre(
30
+ EditGates::Context.new(tool_name: "Write", file_path: file_path, harness: "claude")
31
+ )
32
32
  exit 0