@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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: UTF-8
3
- # Usage: hook-session-start <index_path> <store_root> <mode> [plugin_root]
3
+ # Usage: hook-session-start <index_path> <plastic_home> <mode> [plugin_root]
4
4
  # Outputs hook JSON for the session-start hook. Exits silently if nothing to show.
5
5
 
6
6
  require "json"
@@ -9,10 +9,16 @@ require "yaml"
9
9
  require_relative "lib/bridge"
10
10
  require_relative "lib/boot_banner"
11
11
  require_relative "lib/qmd_sync"
12
- require_relative "doctor"
12
+ require_relative "lib/doctor_core"
13
13
 
14
- index_path, store_root, mode, plugin_root = ARGV
15
- exit 0 unless index_path && store_root && mode
14
+ index_path, plastic_home, mode, plugin_root = ARGV
15
+ exit 0 unless index_path && plastic_home && mode
16
+
17
+ # Plastic home and the store are two different paths (intent 231). The shim passes
18
+ # home (~/.plastic) as argument 2; the store lives one level below it. Compose the
19
+ # store exactly once here, so no later line re-derives it and no path can gain a
20
+ # doubled store/ segment.
21
+ store_dir = File.join(plastic_home, "store")
16
22
 
17
23
  # --- Parse INDEX.md ---
18
24
 
@@ -47,11 +53,11 @@ end
47
53
  bridge_data = nil
48
54
  if active.length == 1 && active.first =~ /store\/([\w-]+)\//
49
55
  dir_name = $1
50
- intent_dir = "#{store_root}/store/#{dir_name}"
56
+ intent_dir = "#{store_dir}/#{dir_name}"
51
57
  session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
52
58
  intent_id = dir_name.split("--").first
53
59
  intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
54
- bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_root, name: intent_name)
60
+ bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_dir, name: intent_name)
55
61
  end
56
62
 
57
63
  # --- Detect stale future intents ---
@@ -68,7 +74,7 @@ stale_days = 3 if stale_days == 0
68
74
  future.each do |f|
69
75
  if f =~ /store\/([\w-]+)\//
70
76
  dir_name = $1
71
- intent_file = "#{store_root}/store/#{dir_name}/#{dir_name}.md"
77
+ intent_file = "#{store_dir}/#{dir_name}/#{dir_name}.md"
72
78
  next unless File.exist?(intent_file)
73
79
 
74
80
  content = File.read(intent_file)
@@ -88,7 +94,7 @@ current_project = nil
88
94
  project_active = []
89
95
  project_future = []
90
96
 
91
- projects_path = "#{store_root}/projects.yml"
97
+ projects_path = "#{plastic_home}/projects.yml"
92
98
  if File.exist?(projects_path)
93
99
  projects = YAML.safe_load(File.read(projects_path)) rescue {}
94
100
  cwd = Dir.pwd
@@ -96,7 +102,7 @@ if File.exist?(projects_path)
96
102
  project_path = File.expand_path(info["path"])
97
103
  if cwd.start_with?(project_path)
98
104
  current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
99
- project_index = "#{store_root}/projects/#{slug}/INDEX.md"
105
+ project_index = "#{plastic_home}/projects/#{slug}/INDEX.md"
100
106
  if File.exist?(project_index)
101
107
  p_section = nil
102
108
  File.readlines(project_index).each do |pline|
@@ -122,7 +128,7 @@ end
122
128
 
123
129
  # --- Load PLASTIC.md conventions ---
124
130
 
125
- plastic_md_path = "#{store_root}/PLASTIC.md"
131
+ plastic_md_path = "#{plastic_home}/PLASTIC.md"
126
132
  plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
127
133
 
128
134
  # --- Load deprecations ---
@@ -130,7 +136,7 @@ plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : n
130
136
  dep_file = if plugin_root && !plugin_root.empty?
131
137
  "#{plugin_root}/deprecations.yml"
132
138
  else
133
- "#{store_root}/deprecations.yml"
139
+ "#{plastic_home}/deprecations.yml"
134
140
  end
135
141
 
136
142
  deprecations = []
@@ -147,7 +153,7 @@ rescue
147
153
  end
148
154
 
149
155
  current_version = nil
150
- version_file = "#{store_root}/VERSION"
156
+ version_file = "#{plastic_home}/VERSION"
151
157
  if File.exist?(version_file)
152
158
  current_version = File.read(version_file).strip
153
159
  elsif plugin_root && !plugin_root.empty?
@@ -167,7 +173,7 @@ end
167
173
  # --- Check for available updates (from previous session's check) ---
168
174
 
169
175
  update_notice = nil
170
- cache_file = "#{store_root}/.cache/update-check.json"
176
+ cache_file = "#{plastic_home}/.cache/update-check.json"
171
177
  if File.exist?(cache_file)
172
178
  cache = JSON.parse(File.read(cache_file)) rescue {}
173
179
  if cache["updateAvailable"]
@@ -181,7 +187,7 @@ end
181
187
  # session: any failure or exception degrades to a banner and we continue.
182
188
 
183
189
  core_health = begin
184
- Doctor.new(plastic_home: store_root).run_core_checks("claude")
190
+ Doctor.new(plastic_home: plastic_home).run_core_checks("claude")
185
191
  rescue
186
192
  nil
187
193
  end
@@ -212,7 +218,7 @@ parts << ""
212
218
  # slow/broken/missing qmd appends nothing and the hook continues cleanly.
213
219
  begin
214
220
  require "timeout"
215
- qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: store_root) }
221
+ qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: plastic_home) }
216
222
  if qmd_status[:present]
217
223
  if qmd_status[:all_registered]
218
224
  parts << "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
@@ -13,16 +13,27 @@
13
13
  # Part 4] the hook stdin schema, including `tool_input.command` as the carrier of
14
14
  # the apply_patch envelope text (consumed by scripts/codex-hook).
15
15
  #
16
- # Residual gap (in neither the guide nor 181, per Decision 14): the apply_patch
17
- # V4A envelope INNER grammar parsed below (`*** Begin/End Patch`, `*** Add/Update/
18
- # Delete File:`, `*** Move to:`, `+`/`-`/context lines) is not primary-sourced.
19
- # There is no live Codex to verify it against (the owner has none installed), so
20
- # this parser is built to the best-known public V4A shape and FAILS OPEN on
21
- # anything else. Returns [] and warns on any missing or unparseable envelope:
22
- # gates fail OPEN (orchestrator-locks fail-open rule). The PostToolUse gate-check
23
- # artifact backstop re-validates the intent file after the write, so a fail-open
24
- # create-gate is still netted. A real Codex run after delivery is the only future
25
- # check on this residual.
16
+ # Grammar provenance (intent 239, 2026-08-12). The V4A envelope grammar parsed below
17
+ # is primary-sourced: it is embedded verbatim in the native codex binary and was
18
+ # extracted from codex-cli 0.146.0 into test/fixtures/codex-v4a-grammar.txt, which
19
+ # carries the binary's path, size and SHA256 in its header.
20
+ # test/codex_v4a_grammar_test.rb checks this parser's markers against that grammar,
21
+ # and a live test re-extracts from an installed binary so the fixture cannot drift
22
+ # (it skips cleanly on a machine with no codex).
23
+ #
24
+ # Two deliberate differences from the real grammar, both in the safe direction:
25
+ # codex requires the envelope's first line to be "*** Begin Patch" and its last to be
26
+ # "*** End Patch", while this parser scans for the markers on any line of the
27
+ # payload, not only the first and last; and "*** Environment ID:", a real production
28
+ # of the grammar, is ignored here rather than parsed, because it names no file
29
+ # operation. Both markers still must form a WHOLE LINE (start-of-text or a
30
+ # preceding LF, and end-of-text or a following LF): per add_line: "+" /(.*)/ LF, a
31
+ # content line such as "+*** End Patch" is legal file content, not a terminator, so a
32
+ # bare substring search would misparse it as the real marker (see
33
+ # line_anchored_index below). Anything else unparseable FAILS OPEN: parse returns []
34
+ # and warns, so gates allow the write (orchestrator-locks fail-open rule), and the
35
+ # PostToolUse gate-check backstop re-validates the intent file after the write
36
+ # lands.
26
37
  module ApplyPatchEnvelope
27
38
  module_function
28
39
 
@@ -39,9 +50,11 @@ module ApplyPatchEnvelope
39
50
  # patch text by scanning for the Begin/End markers regardless of wrapping.
40
51
  def parse(command)
41
52
  text = command.is_a?(Array) ? command.join("\n") : command.to_s
42
- b = text.index(BEGIN_MARK)
43
- e = text.index(END_MARK)
44
- return warn_empty("no Begin/End Patch markers") if b.nil? || e.nil? || e < b
53
+ b = line_anchored_index(text, BEGIN_MARK, 0)
54
+ return warn_empty("no Begin/End Patch markers") if b.nil?
55
+
56
+ e = line_anchored_index(text, END_MARK, b + BEGIN_MARK.length)
57
+ return warn_empty("no Begin/End Patch markers") if e.nil?
45
58
 
46
59
  body = text[(b + BEGIN_MARK.length)...e]
47
60
  ops = []
@@ -73,4 +86,24 @@ module ApplyPatchEnvelope
73
86
  $stderr.puts "plastic apply_patch parse: #{reason}; gate fails open"
74
87
  []
75
88
  end
89
+
90
+ # Finds the first occurrence of `mark` starting at or after `from` that forms a
91
+ # WHOLE LINE: immediately preceded by start-of-text or a newline, and immediately
92
+ # followed by end-of-text or a newline. A bare `text.index(mark)` would also match
93
+ # `mark` sitting inside a longer line, such as the content line "+*** End Patch"
94
+ # (legal per add_line: "+" /(.*)/ LF), which is not a terminator.
95
+ def line_anchored_index(text, mark, from)
96
+ pos = from
97
+ loop do
98
+ idx = text.index(mark, pos)
99
+ return nil if idx.nil?
100
+
101
+ line_start = idx.zero? || text[idx - 1] == "\n"
102
+ after = idx + mark.length
103
+ line_end = after == text.length || text[after] == "\n"
104
+ return idx if line_start && line_end
105
+
106
+ pos = idx + 1
107
+ end
108
+ end
76
109
  end
@@ -9,6 +9,7 @@ require "digest"
9
9
  require "socket"
10
10
  require_relative "worktree"
11
11
  require_relative "lock"
12
+ require_relative "spec_header"
12
13
 
13
14
  module Bridge
14
15
  STAGES = %w[what why how exec done].freeze
@@ -662,13 +663,10 @@ module Bridge
662
663
  # matching the skill and agent contracts). Returns nil when spec.md is
663
664
  # absent, empty, or its first line does not match, so a missing/malformed
664
665
  # Tier line changes nothing about existing rebuild behavior.
666
+ # The grammar itself now lives in SpecHeader (scripts/lib/spec_header.rb, intent 213);
667
+ # this method is a thin read on top of it.
665
668
  def self.savepoint_tier(intent_dir)
666
- path = File.join(intent_dir, "spec.md")
667
- return nil unless File.exist?(path)
668
- first = File.open(path, &:gets)
669
- return nil if first.nil?
670
- m = first.chomp.strip.match(/\ATier:\s*(S|M|L)\z/)
671
- m && m[1]
669
+ SpecHeader.parse_file(File.join(intent_dir, "spec.md"))[:tier]
672
670
  end
673
671
 
674
672
  # Reconstruct the ledger from files on disk (timestamps from mtimes), in
@@ -767,12 +765,16 @@ module Bridge
767
765
  phantoms
768
766
  end
769
767
 
770
- def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
768
+ # Pure compute (intent 230): build the bridge state and write NOTHING. `derive`
769
+ # is the writing wrapper over this; `arm` and `repair_lock` use the pure form so
770
+ # the single bridge write happens only after the delivery lock and the worktree
771
+ # have both settled. Joins the pure `derive_stage` / `derive_key` family.
772
+ def self.derive_data(session, intent_id:, intent_dir:, store:, name:)
771
773
  stage = derive_stage(intent_dir)
772
774
  has = has_files(intent_dir)
773
775
  missing = missing_for_stage(stage, intent_dir) - has
774
776
 
775
- data = {
777
+ {
776
778
  "session" => session,
777
779
  "intent" => {
778
780
  "id" => intent_id,
@@ -816,7 +818,14 @@ module Bridge
816
818
  "delegates" => []
817
819
  }
818
820
  }
821
+ end
819
822
 
823
+ # Compute AND persist. Contract unchanged (intent 230 kept it deliberately):
824
+ # `scripts/hook-session-start` calls this and wants the immediate write, and
825
+ # test/bridge_worktree_derive_test.rb pins write-on-call.
826
+ def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
827
+ data = derive_data(session, intent_id: intent_id, intent_dir: intent_dir,
828
+ store: store, name: name)
820
829
  write(session, data, tmp: tmp)
821
830
  data
822
831
  end
@@ -881,6 +890,19 @@ module Bridge
881
890
 
882
891
  # --- Auto mode (intent 27) ---
883
892
 
893
+ # Intent 230: freshly composed state carries worktree.code = nil, so a failed
894
+ # Worktree.provision would have nothing to keep. Seed the block from the bridge
895
+ # already on disk when it names a code path, so provision's keep-rule (see
896
+ # Worktree.provision) can preserve it. Provision SUCCESS overwrites this with
897
+ # the freshly resolved (identical) pointer, so this only matters on failure.
898
+ def self.carry_prior_worktree(data, session, tmp: tmp_dir)
899
+ prior = read(session, intent_id: data.dig("intent", "id"), tmp: tmp)
900
+ block = prior && prior["worktree"]
901
+ data["worktree"] = block if block.is_a?(Hash) && !blank?(block["code"])
902
+ data
903
+ end
904
+ private_class_method :carry_prior_worktree
905
+
884
906
  # Shared arming spine (intent 96): resolve the session key, derive intent state,
885
907
  # set the caller-controlled auto flag, acquire the delivery lock, provision the
886
908
  # per-intent worktrees, persist, and purge terminal bridges. arm_auto (auto: true)
@@ -893,7 +915,10 @@ module Bridge
893
915
  if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
894
916
  $stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
895
917
  end
896
- data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
918
+ # Compute only (intent 230). Nothing reaches disk until the lock is ours and
919
+ # the worktree has settled; a LockHeldError below must leave the previous
920
+ # bridge exactly as it was.
921
+ data = derive_data(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
897
922
  data["build"]["auto"] = auto
898
923
 
899
924
  # Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
@@ -924,6 +949,8 @@ module Bridge
924
949
  "run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
925
950
  end
926
951
 
952
+ carry_prior_worktree(data, key)
953
+
927
954
  # Provision the per-intent worktrees (mandatory code worktree for project
928
955
  # intents; fail-open for non-git / global-only). Never let a provision error
929
956
  # break arming: the lock and auto flag still matter.
@@ -959,13 +986,24 @@ module Bridge
959
986
  # Degrade path for disarm_auto when no intent_id is given (intent 131): the
960
987
  # session's sole per-intent bridge when there is exactly one, else the
961
988
  # legacy single-key file. Keeps the common single-intent auto path working
962
- # without every caller having to name the intent id explicitly.
989
+ # without every caller having to name the intent id explicitly. With TWO or
990
+ # more per-intent bridges it refuses to guess (intent 233); see below.
963
991
  def self.sole_bridge_data(session, tmp: tmp_dir)
964
992
  matches = Dir.glob(File.join(tmp, "plastic-#{session}--*.json")).reject { |f| f.end_with?(".tmp") }
965
993
  if matches.length == 1
966
994
  data = (JSON.parse(File.read(matches.first)) rescue nil)
967
995
  return data if data
968
996
  end
997
+ # Refuse to guess among siblings (intent 233): with 2+ per-intent bridges
998
+ # the legacy single-key file below can carry EITHER sibling, so falling
999
+ # through would let a no-id disarm release the wrong intent's lock. A
1000
+ # disarm that does nothing is recoverable; one that unlocks a live
1001
+ # delivery is not. Zero matches keeps the legacy fallback (131 migration).
1002
+ if matches.length > 1
1003
+ $stderr.puts "plastic: session #{session} has #{matches.length} bridges; " \
1004
+ "disarm needs an explicit intent_id (refusing to guess)"
1005
+ return nil
1006
+ end
969
1007
  read(session, tmp: tmp)
970
1008
  end
971
1009
 
@@ -979,6 +1017,9 @@ module Bridge
979
1017
  # (one per concurrent intent), so disarm must target ONE of them. When
980
1018
  # intent_id is nil, degrades to the session's sole bridge (see
981
1019
  # sole_bridge_data) so the common single-intent path keeps working.
1020
+ #
1021
+ # The lock clear is conditional (intent 233): a failed release leaves the
1022
+ # cached lock fields alone so the orphan stays findable.
982
1023
  def self.disarm_auto(session, intent_id: nil)
983
1024
  data = blank?(intent_id) ? sole_bridge_data(session) : read(session, intent_id: intent_id)
984
1025
  return nil unless data
@@ -994,14 +1035,39 @@ module Bridge
994
1035
  $stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
995
1036
  end
996
1037
 
1038
+ # Check what the release actually DID before touching the cache (intent
1039
+ # 233). Blanking the cache after a failed release orphans the durable
1040
+ # delivery.lock: the file stays on disk and nothing points at it any more.
1041
+ # Rescue mirrors the Worktree.release rescue above: warn, never raise,
1042
+ # never abort the rest of the tail (the guard fails milder than the bug).
997
1043
  dir = bridge_intent_dir(data)
1044
+ release_status = nil
998
1045
  if dir
999
1046
  owner = data.dig("lock", "owner_session")
1000
1047
  owner = session if blank?(owner)
1001
- Lock.release(dir, session: owner)
1048
+ begin
1049
+ release_status = Lock.release(dir, session: owner)
1050
+ rescue => e
1051
+ release_status = :raised
1052
+ $stderr.puts "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
1053
+ end
1002
1054
  end
1003
- data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
1004
- "host" => nil, "type" => nil, "delegates" => [] }
1055
+
1056
+ # Success means "no lock left on disk": :released (we deleted it), :none
1057
+ # (there was none), and nil (no intent dir, so no release was attempted).
1058
+ # Only :not_owner and a raised release keep the cache pointing at the lock
1059
+ # so plastic-lock fix / reclaim / doctor can still find and repair it.
1060
+ if release_status.nil? || release_status == :released || release_status == :none
1061
+ data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
1062
+ "host" => nil, "type" => nil, "delegates" => [] }
1063
+ else
1064
+ $stderr.puts "plastic: delivery lock NOT released for #{dir} (#{release_status}); " \
1065
+ "bridge lock cache preserved for repair"
1066
+ data["lock"] = {} unless data["lock"].is_a?(Hash)
1067
+ end
1068
+ # Diagnostic only (D5): the primary contract stays "owner_session non-nil
1069
+ # after disarm means the lock was not released".
1070
+ data["lock"]["release_status"] = release_status.nil? ? nil : release_status.to_s
1005
1071
 
1006
1072
  write(session, data)
1007
1073
  purge_done_bridges(session: session)
@@ -1070,11 +1136,13 @@ module Bridge
1070
1136
  actions << "lock #{status}"
1071
1137
  end
1072
1138
 
1073
- data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
1074
- name: name, tmp: tmp)
1139
+ data = derive_data(key, intent_id: intent_id, intent_dir: dir, store: store,
1140
+ name: name)
1075
1141
  data["build"]["auto"] = auto
1076
1142
  data["lock"] = lock_cache(lock_data)
1077
1143
 
1144
+ carry_prior_worktree(data, key, tmp: tmp)
1145
+
1078
1146
  # Provision the per-intent worktrees so the rebuilt bridge carries
1079
1147
  # worktree.code (intent 136). Without it, cwd/edited-path selection has no
1080
1148
  # key: the repaired intent loses its own code gate and a concurrent sibling
@@ -1598,8 +1666,8 @@ module Bridge
1598
1666
  end
1599
1667
 
1600
1668
  # 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.
1669
+ # sanctioned bash/interpreter writes. The hook logs every use to
1670
+ # ~/.plastic/.cache/gate-escapes.log.
1603
1671
  PLASTIC_OK_RE = /(?:\A|\s)#\s*plastic-ok\s*\z/.freeze
1604
1672
 
1605
1673
  def self.bash_escape?(command)
@@ -0,0 +1,138 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # CodexEditGates (intent 251): the merged Codex apply_patch edit path. One
5
+ # process, one stdin parse, one envelope parse, five gates in-process.
6
+ #
7
+ # Before this, ~/.codex/hooks.json registered five separate PreToolUse commands
8
+ # on the apply_patch matcher and three of them spawned a further run_core child
9
+ # per file operation: eight processes per edit, ten across a full edit cycle.
10
+ # This is the Codex mirror of what intent 244 did for Claude (spec D-e of that
11
+ # intent parked it as a successor).
12
+ #
13
+ # Every DECISION here comes from scripts/lib/edit_gates.rb, the same functions
14
+ # scripts/hook-edit-gates drives for Claude. This file contributes exactly three
15
+ # Codex-specific things and nothing else:
16
+ # 1. the per-operation Context (the patch envelope carries a path plus ADDED
17
+ # lines, not Claude's file_path plus content pair),
18
+ # 2. the add-only rule for create-gate (Update/Delete/Move defer to the
19
+ # PostToolUse gate-check backstop, intent 102 spec Decision 5), and
20
+ # 3. the two-pass evaluation that keeps savepoint-pre's ledger append
21
+ # unconditional across a multi-operation patch (intent 244 spec D-c).
22
+ #
23
+ # NO SUBPROCESS MAY EVER BE ADDED TO THIS FILE. That is the whole point of the
24
+ # intent, and test/codex_edit_gates_test.rb asserts it against this source.
25
+ require "stringio"
26
+ require_relative "edit_gates"
27
+ require_relative "hook_registry"
28
+
29
+ module CodexEditGates
30
+ module_function
31
+
32
+ # Pass 1 and pass 2 of the two-pass evaluation (spec D3). Both slices preserve
33
+ # HookRegistry::CODEX_GATE_TOOLS key order, which is Claude's evaluation order
34
+ # (spec D5): savepoint-pre, lock-gate, code-gate, links-gate, create-gate.
35
+ SAVEPOINT_TOOLS = HookRegistry::CODEX_GATE_TOOLS.select { |gate, _| gate == "savepoint-pre" }.freeze
36
+ DENY_TOOLS = HookRegistry::CODEX_GATE_TOOLS.reject { |gate, _| gate == "savepoint-pre" }.freeze
37
+
38
+ # One Context per file operation. gate_path and file_path both carry the
39
+ # envelope's raw path, exactly as the five popened cores received it in ARGV[0]
40
+ # before the merge. content is always a String (never nil), because
41
+ # ApplyPatchEnvelope gives a Delete op a nil added_content and today's
42
+ # dispatcher already called .to_s on it at every call site. harness is "codex"
43
+ # so lock-gate and the claim gate render deny reasons in the $plastic-<name>
44
+ # form (intent 201 D2).
45
+ def context_for(payload, op, env: ENV)
46
+ session = payload["session_id"]
47
+ session = env["CLAUDE_CODE_SESSION_ID"] if session.to_s.empty?
48
+
49
+ EditGates::Context.new(
50
+ tool_name: payload["tool_name"],
51
+ session: (session unless session.to_s.empty?),
52
+ gate_path: op.path,
53
+ file_path: op.path,
54
+ savepoint_path: op.path,
55
+ content: op.added_content.to_s,
56
+ harness: "codex",
57
+ )
58
+ end
59
+
60
+ # create-gate is a pre-write veto for ADD operations only (spec D2).
61
+ # EditGates.create_gate has no such rule: fed a partial Update hunk as content
62
+ # it would run IntentValidator on a fragment and FALSE-DENY a legitimate write.
63
+ def create_gate(ctx, op)
64
+ return EditGates::ALLOW unless op.op == :add
65
+ EditGates.create_gate(ctx)
66
+ end
67
+
68
+ def route_for(op)
69
+ lambda do |gate, ctx|
70
+ case gate
71
+ when "savepoint-pre" then EditGates.savepoint_pre(ctx)
72
+ when "lock-gate" then EditGates.lock_gate(ctx)
73
+ when "code-gate" then EditGates.code_gate(ctx)
74
+ when "links-gate" then EditGates.links_gate(ctx)
75
+ when "create-gate" then create_gate(ctx, op)
76
+ end
77
+ end
78
+ end
79
+
80
+ # True iff tool_name is a NON-EMPTY string that no CODEX_GATE_TOOLS entry
81
+ # matches. An empty/missing tool_name already means "the harness matched but
82
+ # did not name the tool" and every gate runs (EditGates.tool_applies?'s own
83
+ # empty-name rule); this is the OTHER unmatched case, a non-empty tool_name
84
+ # this dispatcher does not recognize. Checked once against "apply_patch",
85
+ # since every CODEX_GATE_TOOLS value is the identical single-entry list.
86
+ def unexpected_tool_name?(tool_name)
87
+ return false if tool_name.to_s.empty?
88
+ !EditGates.tool_applies?(tool_name, %w[apply_patch])
89
+ end
90
+
91
+ # Returns the process exit code. Two passes (spec D3):
92
+ # pass 1 runs savepoint-pre over EVERY op. It never denies, and running it
93
+ # before any deny check keeps its started ledger append unconditional, so
94
+ # op B's ledger line still lands when op A is blocked.
95
+ # pass 2 runs the four denying gates per op, first deny wins.
96
+ # EditGates.dispatch returns 0 both for "allowed" and for lock-gate's JSON
97
+ # deny, so pass 2 captures stdout per op: a non-empty buffer IS a deny, and it
98
+ # is printed and evaluation stops. Without that capture a second op could emit
99
+ # a second deny JSON.
100
+ #
101
+ # Codex only ever invokes this dispatcher from the apply_patch matcher, so by
102
+ # the time we get here the harness HAS already matched, regardless of what
103
+ # tool_name the payload itself carries. A non-empty, unrecognized tool_name
104
+ # (a future Codex rename, or a malformed/adversarial payload) must therefore
105
+ # never silently skip every gate: that is the coverage WEAKENING
106
+ # EditGates.tool_applies?'s own empty-name rule exists to prevent, just
107
+ # triggered from the opposite direction (an unrecognized name here, not a
108
+ # missing one there). Loud and safe: warn once to err and run all five gates
109
+ # exactly as if tool_name had been "apply_patch", rather than silent and
110
+ # permissive.
111
+ def dispatch(ops:, payload:, out: $stdout, err: $stderr)
112
+ if unexpected_tool_name?(payload["tool_name"])
113
+ err.puts "plastic codex edit-gates: unexpected tool_name #{payload["tool_name"].inspect}; " \
114
+ "the apply_patch matcher already selected this dispatcher, so all five gates run anyway"
115
+ payload = payload.merge("tool_name" => "apply_patch")
116
+ end
117
+
118
+ pairs = ops.map { |op| [op, context_for(payload, op)] }
119
+
120
+ pairs.each do |op, ctx|
121
+ EditGates.dispatch(ctx: ctx, route: route_for(op), gate_tools: SAVEPOINT_TOOLS,
122
+ out: StringIO.new, err: err)
123
+ end
124
+
125
+ pairs.each do |op, ctx|
126
+ buffer = StringIO.new
127
+ code = EditGates.dispatch(ctx: ctx, route: route_for(op), gate_tools: DENY_TOOLS,
128
+ out: buffer, err: err)
129
+ return code unless code.zero?
130
+ next if buffer.string.empty?
131
+
132
+ out.print buffer.string
133
+ return 0
134
+ end
135
+
136
+ 0
137
+ end
138
+ end