@zalom/plastic 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +94 -556
- package/README.md +18 -6
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-intent-curator.md +2 -2
- package/agents/plastic-intent-discovery.md +7 -0
- package/bin/plastic.js +17 -8
- package/deprecations.yml +10 -2
- package/hooks/auto-arm +2 -2
- package/hooks/bash-gate +1 -1
- package/hooks/check-update +1 -1
- package/hooks/continue +2 -2
- package/hooks/edit-gates +3 -0
- package/hooks/future-intent-check +2 -2
- package/hooks/gate-check +3 -3
- package/hooks/hooks.json +4 -44
- package/hooks/power-tools +8 -0
- package/hooks/session-start +1 -1
- package/package.json +1 -2
- package/scripts/codex-hook +57 -118
- package/scripts/doctor.rb +146 -1012
- package/scripts/exec-worktree +103 -0
- package/scripts/hash-intent +1 -1
- package/scripts/hook-bash-gate +19 -0
- package/scripts/hook-code-gate +16 -28
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +13 -57
- package/scripts/hook-edit-gates +58 -0
- package/scripts/hook-gate-check +17 -0
- package/scripts/hook-links-gate +11 -31
- package/scripts/hook-lock-gate +19 -60
- package/scripts/hook-power-tools +38 -0
- package/scripts/hook-savepoint-pre +11 -11
- package/scripts/hook-session-start +21 -15
- package/scripts/lib/apply_patch_envelope.rb +46 -13
- package/scripts/lib/bridge.rb +85 -17
- package/scripts/lib/codex_edit_gates.rb +138 -0
- package/scripts/lib/doctor_core.rb +1087 -0
- package/scripts/lib/edit_gates.rb +398 -0
- package/scripts/lib/exec_worktree.rb +325 -0
- package/scripts/lib/harness_text.rb +57 -0
- package/scripts/lib/hook_registry.rb +64 -28
- package/scripts/lib/installer_core.rb +166 -24
- package/scripts/lib/lock.rb +196 -47
- package/scripts/lib/qmd_hook.rb +21 -41
- package/scripts/lib/qmd_sync.rb +0 -15
- package/scripts/lib/revisions_writer.rb +1 -1
- package/scripts/lib/ruby_probe.rb +60 -0
- package/scripts/lib/scaffold_intent.rb +392 -0
- package/scripts/lib/spec_header.rb +83 -0
- package/scripts/lib/start_intent.rb +296 -0
- package/scripts/lib/verify_intent.rb +262 -0
- package/scripts/lib/worktree.rb +15 -1
- package/scripts/link-suggest +1 -1
- package/scripts/maintenance-run +5 -5
- package/scripts/migrate-to-global +2 -2
- package/scripts/restore-intent-v1 +17 -12
- package/scripts/scaffold-intent +120 -0
- package/scripts/start-intent +89 -0
- package/scripts/verify-intent +73 -0
- package/skills/agent-advisor/SKILL.md +5 -5
- package/skills/auto/SKILL.md +52 -32
- package/skills/auto/evals/evals.json +2 -2
- package/skills/auto/references/agent-architecture.md +1 -1
- package/skills/auto/references/agent-report-contract.md +1 -1
- package/skills/auto/references/human-report-contract.md +22 -3
- package/skills/auto/references/tiers.md +24 -2
- package/skills/conventions/SKILL.md +31 -0
- package/skills/conventions/references/completion-and-done.md +43 -0
- package/skills/conventions/references/gates-and-enforcement.md +39 -0
- package/skills/conventions/references/knowledge-graph.md +47 -0
- package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
- package/skills/conventions/references/locks-and-worktrees.md +113 -0
- package/skills/conventions/references/maintenance-and-revisions.md +170 -0
- package/skills/conventions/references/roadmaps.md +44 -0
- package/skills/conventions/references/tiers-and-dispatch.md +135 -0
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +15 -6
- package/skills/doctor/references/gates-stuck-detection.md +13 -8
- package/skills/doctor/report.md +1 -1
- package/skills/install/SKILL.md +1 -1
- package/skills/intent-brainstorming/SKILL.md +4 -2
- package/skills/intent-continuing/SKILL.md +4 -0
- package/skills/intent-creating/SKILL.md +13 -8
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-discovering/SKILL.md +10 -3
- package/skills/intent-ending/SKILL.md +12 -7
- package/skills/intent-executing/SKILL.md +37 -19
- package/skills/intent-grilling/SKILL.md +5 -3
- package/skills/intent-linking/SKILL.md +4 -0
- package/skills/intent-locking/SKILL.md +4 -0
- package/skills/intent-planning/SKILL.md +14 -3
- package/skills/intent-researching/SKILL.md +0 -2
- package/skills/intent-savepoint/SKILL.md +4 -0
- package/skills/intent-speccing/SKILL.md +4 -0
- package/skills/intent-starting/SKILL.md +20 -2
- package/skills/project-creating/SKILL.md +0 -2
- package/skills/project-creating/references/project-scaffolding.md +3 -3
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/promotion-and-tagging.md +14 -8
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +4 -0
- package/skills/roadmap-continuing/SKILL.md +4 -0
- package/skills/skill-creating/SKILL.md +8 -2
- package/skills/skill-creating/references/defaults-first.md +23 -0
- package/skills/skill-creating/references/hooks.md +4 -1
- package/skills/store-curating/SKILL.md +8 -0
- package/skills/store-indexing/SKILL.md +16 -5
- package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
- package/skills/tutorial/references/track-1-guided.md +2 -2
- package/skills/tutorial/references/track-2-auto.md +10 -8
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
- package/skills/uninstall/SKILL.md +6 -9
- package/templates/agents.md +12 -12
- package/templates/config.yml +6 -7
- package/templates/index.md +6 -3
- package/templates/spec.md +1 -1
- package/PLASTIC-reference.md +0 -138
- package/hooks/code-gate +0 -27
- package/hooks/create-gate +0 -3
- package/hooks/links-gate +0 -3
- package/hooks/lock-gate +0 -21
- package/hooks/qmd-search +0 -8
- package/hooks/retrieval-gate +0 -10
- package/hooks/savepoint-pre +0 -10
- package/scripts/hook-qmd-search +0 -44
- package/scripts/hook-retrieval-gate +0 -148
- package/scripts/lib/retrieval_gate.rb +0 -211
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# encoding: UTF-8
|
|
3
|
-
# Usage: hook-session-start <index_path> <
|
|
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 "
|
|
12
|
+
require_relative "lib/doctor_core"
|
|
13
13
|
|
|
14
|
-
index_path,
|
|
15
|
-
exit 0 unless index_path &&
|
|
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 = "#{
|
|
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:
|
|
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 = "#{
|
|
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 = "#{
|
|
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 = "#{
|
|
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 = "#{
|
|
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
|
-
"#{
|
|
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 = "#{
|
|
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 = "#{
|
|
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:
|
|
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:
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# this parser
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
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 =
|
|
1074
|
-
|
|
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
|
|
1602
|
-
#
|
|
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
|