@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,325 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "open3"
5
+ require_relative "bridge"
6
+ require_relative "lock"
7
+ require_relative "worktree"
8
+ require_relative "scaffold_intent"
9
+
10
+ # ExecWorktree - all logic for `scripts/exec-worktree` (intent 213, group 2). Finish an
11
+ # intent's code worktree: an order precondition, then `Worktree.finish` (delivered merges
12
+ # and removes, abandoned removes only).
13
+ #
14
+ # THIS IS THE HIGHEST-RISK SURFACE IN THIS ACTION SET because it composes worktree
15
+ # arbitration. The rule for that surface is compose the existing primitives, add nothing
16
+ # (spec D6).
17
+ #
18
+ # NEVER REIMPLEMENTS WORKTREE ARBITRATION. `Worktree.finish` (scripts/lib/worktree.rb:225)
19
+ # already does merge-then-remove or remove-only, fail-open, idempotent throughout. This
20
+ # module writes no git worktree, merge, or branch mechanics of its own, and never calls
21
+ # either of the Lock module's release or takeover operations (that unguarded
22
+ # read-modify-write gap is parked intent 254's territory).
23
+ #
24
+ # THE ORDER-PRECONDITION TRAP (spec D7, binding). `Bridge.code_gate_decision` returns nil
25
+ # UNCONDITIONALLY when the bridge is guided (`build["auto"] != true`), so as a precondition
26
+ # it is VACUOUS in guided mode. This module calls that predicate exactly as it exists,
27
+ # unmodified, and never copies its "reached How" body into a second predicate (the 200/204
28
+ # duplication failure pattern). Instead it states plainly, in the printed output, that on a
29
+ # guided bridge the precondition is advisory only, and that the hook layer
30
+ # (`Bridge.code_gate_decision`, wired through `scripts/lib/edit_gates.rb`) remains the
31
+ # actual enforcement point.
32
+ #
33
+ # Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` directly.
34
+ # Seams: `runner:` (git, defaults to `Worktree::ShellRunner.new`; also used AFTER finish, for
35
+ # a delivered disposition, to verify from committed git state that the merge actually landed,
36
+ # since `Worktree.finish`'s return value is discarded by design and its underlying merge is
37
+ # fail-open, see the merge-verification note below), `finisher:` (defaults to
38
+ # `Worktree.method(:finish)`), `status_checker:` (defaults to a `git status --porcelain`
39
+ # lambda), `gate:` (defaults to `Bridge.method(:code_gate_decision)`). Every seam exists so
40
+ # tests run with no real git, no real worktree removal, and no real bridge file outside a
41
+ # tmpdir.
42
+ #
43
+ # MERGE VERIFICATION (delivered disposition only). `Worktree.merge_branch` is deliberately
44
+ # fail-open: on a conflict it runs `git merge --abort`, warns, and returns false, and
45
+ # `Worktree.finish` still removes the worktree afterward regardless. So a conflicted merge
46
+ # and a clean merge look identical from this module's point of view unless it checks. This
47
+ # module never trusts the discarded return value; it asks git directly, AFTER finish, whether
48
+ # the intent's code branch is now an ancestor of the repo's current HEAD
49
+ # (`merged_into_current_branch?` below). `repo` and `branch` are captured BEFORE the finisher
50
+ # runs, because finish deletes the bridge's `worktree` block.
51
+ #
52
+ # DOES NOT RUN A TEST SUITE, in any form. `verify-intent` owns test execution; one concern
53
+ # per script (spec D8).
54
+ module ExecWorktree
55
+ module_function
56
+
57
+ DISPOSITIONS = %w[delivered abandoned].freeze
58
+
59
+ EXIT_OK = 0
60
+ EXIT_USAGE = 1
61
+ EXIT_PRECONDITION = 2
62
+ EXIT_NOT_CLEAN = 3
63
+ EXIT_UNRESOLVED = 4
64
+
65
+ PROBE_FILENAME = "exec-worktree-precondition-probe"
66
+
67
+ # The advisory line, verbatim (spec D7): printed whenever the order precondition
68
+ # actually ran (delivered disposition, a code worktree present), on BOTH the pass and
69
+ # the fail path, so no reader mistakes this script for the enforcement point.
70
+ ADVISORY_LINE =
71
+ "This precondition is a friendly early error, not the enforcement point. The hook " \
72
+ "layer (scripts/lib/bridge.rb code_gate_decision, wired through " \
73
+ "scripts/lib/edit_gates.rb) remains the actual gate. On a guided bridge this " \
74
+ "precondition is advisory only."
75
+
76
+ # --- seams (DI defaults) ----------------------------------------------------------
77
+
78
+ # Real `git status --porcelain` against the code worktree, returning the same
79
+ # [stdout, stderr, status] triple Open3.capture3 returns (status responds to
80
+ # success?). A raised error (git missing, corrupt repo) is caught and reported as a
81
+ # nil status, so the caller can fail closed the same way a failed exit status would.
82
+ def default_status_checker
83
+ lambda do |worktree_code|
84
+ Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
85
+ rescue StandardError => e
86
+ [nil, e.message, nil]
87
+ end
88
+ end
89
+
90
+ # --- pure helpers ------------------------------------------------------------------
91
+
92
+ # Normalize either spelling of --home to the OS-HOME level (the PARENT of `.plastic`),
93
+ # which is what `Worktree.finish`'s `home:` keyword wants
94
+ # (`Worktree.home_from_store`, scripts/lib/worktree.rb:90). When the given path's
95
+ # basename is literally `.plastic`, its dirname is used; otherwise the path is used
96
+ # as given.
97
+ def normalize_home(home)
98
+ expanded = File.expand_path(home.to_s.sub(/\A~/, Dir.home))
99
+ File.basename(expanded) == ".plastic" ? File.dirname(expanded) : expanded
100
+ end
101
+
102
+ # The repo a code worktree belongs to, derived from its own path rather than re-resolved
103
+ # through projects.yml: `Worktree.paths` always builds a code worktree at
104
+ # `<repo>/.claude/worktrees/<name>` (scripts/lib/worktree.rb:75), so walking up three
105
+ # directories recovers `<repo>` deterministically, with no I/O and no git.
106
+ def repo_from_worktree_code(worktree_code)
107
+ return nil if Bridge.blank?(worktree_code)
108
+ File.dirname(File.dirname(File.dirname(worktree_code.to_s)))
109
+ end
110
+
111
+ # BLOCKING 1/2 fix: read committed git state instead of trusting Worktree.finish's
112
+ # discarded return value. True iff `branch` is now an ancestor of `repo`'s current HEAD,
113
+ # i.e. the merge actually landed. Fails CLOSED (not merged) on a blank repo/branch or a
114
+ # raising runner, so an inconclusive check is never reported as a success.
115
+ def merged_into_current_branch?(runner, repo:, branch:)
116
+ return false if Bridge.blank?(repo) || Bridge.blank?(branch)
117
+ runner.run("-C", repo, "merge-base", "--is-ancestor", branch, "HEAD").success?
118
+ rescue StandardError
119
+ false
120
+ end
121
+
122
+ # Session resolution order (mirrors scripts/end-intent:418-426, not
123
+ # scripts/start-intent's no-fallback order): explicit --session, else
124
+ # CLAUDE_CODE_SESSION_ID (passed in as env_session, never read from ENV here), else
125
+ # the existing delivery.lock's own recorded owner_session when non-blank, else nil.
126
+ # The lock-owner fallback is correct here: this is a teardown step on an intent this
127
+ # session has been delivering.
128
+ def resolve_session(explicit, env_session, intent_dir)
129
+ return explicit.to_s.strip unless Bridge.blank?(explicit)
130
+ return env_session.to_s.strip unless Bridge.blank?(env_session)
131
+ lock = Lock.read(intent_dir)
132
+ return nil unless lock
133
+ owner = lock["owner_session"].to_s
134
+ Bridge.blank?(owner) ? nil : owner
135
+ end
136
+
137
+ # --- result builders (mirror StartIntent's exit_code/stdout/stderr shape) --------
138
+
139
+ def usage_result(message)
140
+ { exit_code: EXIT_USAGE, stderr: ["exec-worktree: #{message}"], stdout: [] }
141
+ end
142
+
143
+ def deny_result(code, messages)
144
+ { exit_code: code, stderr: Array(messages), stdout: [] }
145
+ end
146
+
147
+ def ok_result(messages)
148
+ { exit_code: EXIT_OK, stderr: [], stdout: Array(messages) }
149
+ end
150
+
151
+ # --- messages ------------------------------------------------------------------------
152
+
153
+ def no_session_message(intent_dir)
154
+ "exec-worktree: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
155
+ "identity could be resolved (--session, CLAUDE_CODE_SESSION_ID, and the lock's own " \
156
+ "recorded owner are all blank); refusing rather than guessing."
157
+ end
158
+
159
+ def no_bridge_message(intent_dir, session, id)
160
+ "exec-worktree: no bridge resolved for session #{session.inspect} and intent " \
161
+ "#{id.inspect} (#{File.basename(intent_dir)}); any worktree this intent provisioned " \
162
+ "was NOT removed. Run /plastic-doctor to check for an orphaned worktree."
163
+ end
164
+
165
+ def nothing_provisioned_message(intent_dir)
166
+ "exec-worktree: #{File.basename(intent_dir)} has no code worktree recorded on its " \
167
+ "bridge (precondition: skipped, no code worktree); nothing was provisioned, nothing " \
168
+ "to finish."
169
+ end
170
+
171
+ def status_check_failed_message(worktree_code, err)
172
+ "exec-worktree: could not inspect the code worktree at #{worktree_code} (git status " \
173
+ "failed: #{err.to_s.strip}); refusing to remove it, because removal would " \
174
+ "force-discard any uncommitted work."
175
+ end
176
+
177
+ def dirty_worktree_message(worktree_code)
178
+ "exec-worktree: code worktree #{worktree_code} has uncommitted changes; refusing to " \
179
+ "remove it. Commit or stash the changes, or run end-intent " \
180
+ "--discard-worktree-changes to force the close deliberately (that flag belongs to " \
181
+ "end-intent, not this script)."
182
+ end
183
+
184
+ def finish_incomplete_message(worktree_code)
185
+ "exec-worktree: the code worktree at #{worktree_code} is still present after finish; " \
186
+ "teardown did not complete. Run /plastic-doctor to check the worktree/lock status."
187
+ end
188
+
189
+ def merge_not_landed_message(branch)
190
+ "exec-worktree: the merge did not land: #{branch} is not an ancestor of the repo's " \
191
+ "current HEAD after finish; the work is still on #{branch} and was not merged, so " \
192
+ "nothing is lost. Resolve the conflict, then merge #{branch} by hand, or re-run " \
193
+ "finish once it can merge cleanly."
194
+ end
195
+
196
+ # --- report (printed only when the run reaches a normal finish) ------------------
197
+
198
+ def build_report(intent_dir:, disposition:, precondition_status:, worktree_code:, branch:,
199
+ target_branch:, removed:)
200
+ # This is only ever reached for "delivered" AFTER `run` has already confirmed, by
201
+ # reading committed git state (merged_into_current_branch?), that `branch` IS an
202
+ # ancestor of the repo's current HEAD; a merge that did not land returns from `run`
203
+ # before build_report is ever called (BLOCKING 1). So this never asserts an outcome
204
+ # that was not read (BLOCKING 2), and `target_branch` (the repo's OWN current branch,
205
+ # the real integration target) replaces the old, wrong use of `branch` (the SOURCE
206
+ # branch) in this line.
207
+ merge_line =
208
+ if disposition != "delivered"
209
+ "not attempted (abandoned)"
210
+ elsif Bridge.blank?(target_branch)
211
+ "merged (integration target branch could not be resolved)"
212
+ else
213
+ "merged into #{target_branch}"
214
+ end
215
+ [
216
+ "exec-worktree: #{File.basename(intent_dir)}",
217
+ " disposition: #{disposition}",
218
+ " precondition: #{precondition_status}",
219
+ " worktree: #{worktree_code}",
220
+ " branch: #{branch}",
221
+ " merge: #{merge_line}",
222
+ " cleanup: #{removed ? 'removed' : 'still present'}",
223
+ ].join("\n")
224
+ end
225
+
226
+ # --- entry point ---------------------------------------------------------------------
227
+
228
+ def run(store:, id:, home:, disposition:, session:, env_session:,
229
+ runner: Worktree::ShellRunner.new,
230
+ finisher: Worktree.method(:finish),
231
+ status_checker: default_status_checker,
232
+ gate: Bridge.method(:code_gate_decision))
233
+ return usage_result("--store is required") if Bridge.blank?(store)
234
+ return usage_result("--id is required") if Bridge.blank?(id)
235
+ return usage_result("--home is required") if Bridge.blank?(home)
236
+ return usage_result("--disposition is required") if Bridge.blank?(disposition)
237
+ unless DISPOSITIONS.include?(disposition)
238
+ return usage_result("--disposition must be one of #{DISPOSITIONS.join('|')} (got #{disposition.inspect})")
239
+ end
240
+
241
+ store_abs = ScaffoldIntent.expand(store)
242
+ return usage_result("store dir does not exist: #{store_abs}") unless Dir.exist?(store_abs)
243
+
244
+ intent_dir, resolve_err = ScaffoldIntent.resolve_intent_dir(store_abs, id)
245
+ return usage_result(resolve_err) if intent_dir.nil?
246
+
247
+ normalized_home = normalize_home(home)
248
+
249
+ # Step 0: session resolution + pre-flight refusal (D3-mirroring; see resolve_session).
250
+ key_session = resolve_session(session, env_session, intent_dir)
251
+ if Bridge.blank?(key_session) && File.exist?(Lock.path(intent_dir))
252
+ return deny_result(EXIT_UNRESOLVED, no_session_message(intent_dir))
253
+ end
254
+
255
+ bridge_data = Bridge.blank?(key_session) ? nil : Bridge.read(key_session, intent_id: id)
256
+ return ok_result(no_bridge_message(intent_dir, key_session, id)) if bridge_data.nil?
257
+
258
+ worktree_code = bridge_data.dig("worktree", "code")
259
+ return ok_result(nothing_provisioned_message(intent_dir)) if Bridge.blank?(worktree_code)
260
+
261
+ branch = bridge_data.dig("worktree", "code_branch")
262
+ # Captured now, before `finisher.call` below (Worktree.finish) deletes the bridge's
263
+ # `worktree` block, so the post-finish merge verification (BLOCKING 1/2) still has
264
+ # both `repo` and `branch` to work with.
265
+ repo = repo_from_worktree_code(worktree_code)
266
+
267
+ precondition_ran = false
268
+ precondition_status = "not run (abandoned)"
269
+
270
+ if disposition == "delivered"
271
+ precondition_ran = true
272
+
273
+ # Step 1: the order precondition. Call Bridge.code_gate_decision AS-IS (never
274
+ # relaxed, never duplicated). See the module doc's "order-precondition trap".
275
+ probe_path = File.join(worktree_code, PROBE_FILENAME)
276
+ reason = gate.call(bridge_data, probe_path, home: normalized_home)
277
+ if reason
278
+ return deny_result(EXIT_PRECONDITION, [reason, ADVISORY_LINE])
279
+ end
280
+ precondition_status = bridge_data.dig("build", "auto") == true ? "passed" : "advisory (guided bridge)"
281
+
282
+ # Step 2: the dirty-worktree guard, delivered only. Fail CLOSED when the check
283
+ # itself fails or raises: removal force-removes on a plain failure, which would
284
+ # destroy uncommitted work an inconclusive check could not rule out.
285
+ out, err, status = status_checker.call(worktree_code)
286
+ if status.nil? || !status.respond_to?(:success?) || !status.success?
287
+ return deny_result(EXIT_NOT_CLEAN, status_check_failed_message(worktree_code, err))
288
+ elsif !out.to_s.strip.empty?
289
+ return deny_result(EXIT_NOT_CLEAN, dirty_worktree_message(worktree_code))
290
+ end
291
+ end
292
+
293
+ # Step 3: finish. Worktree.finish is fail-open and never raises: on a merge conflict it
294
+ # aborts the merge, warns, and STILL removes the worktree (Worktree.merge_branch,
295
+ # scripts/lib/worktree.rb:246-258). So a failed merge is invisible both in this call's
296
+ # return value (discarded, as before) and in whether the worktree directory still
297
+ # exists (gone either way). Detected afterward in two steps: first, for a delivered
298
+ # disposition only, whether the worktree directory still exists on disk (teardown
299
+ # incomplete); second, for a delivered disposition only, whether the branch actually
300
+ # merged (BLOCKING 1/2, see merged_into_current_branch? above).
301
+ finisher.call(bridge_data, home: normalized_home, runner: runner,
302
+ merge: disposition == "delivered")
303
+
304
+ removed = !Dir.exist?(worktree_code)
305
+ if disposition == "delivered" && !removed
306
+ return deny_result(EXIT_NOT_CLEAN, finish_incomplete_message(worktree_code))
307
+ end
308
+
309
+ target_branch = nil
310
+ if disposition == "delivered"
311
+ unless merged_into_current_branch?(runner, repo: repo, branch: branch)
312
+ return deny_result(EXIT_NOT_CLEAN, merge_not_landed_message(branch))
313
+ end
314
+ target_branch = Worktree.current_branch(runner, repo: repo)
315
+ end
316
+
317
+ report = build_report(intent_dir: intent_dir, disposition: disposition,
318
+ precondition_status: precondition_status,
319
+ worktree_code: worktree_code, branch: branch,
320
+ target_branch: target_branch, removed: removed)
321
+
322
+ stdout = precondition_ran ? [ADVISORY_LINE, report] : [report]
323
+ ok_result(stdout)
324
+ end
325
+ end
@@ -0,0 +1,57 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Install-time harness text projection (intent 239). Plastic authors one skill tree,
5
+ # in Claude Code's dialect, and projects it per harness at COPY time. This mirrors the
6
+ # rewrite install_claude already performs on hook launchers ($SCRIPT_DIR/../scripts/ to
7
+ # $HOME/.plastic/scripts/, installer_core.rb:723-735); it is the same mechanism applied
8
+ # to instruction text instead of shell launchers.
9
+ #
10
+ # Pure String to String. No IO, no filesystem probing, no ENV reads: the caller supplies
11
+ # both the relative source path and the set of real skill directory names.
12
+ module HarnessText
13
+ module_function
14
+
15
+ PLUGIN_ROOT_TOKEN = "${CLAUDE_PLUGIN_ROOT}"
16
+
17
+ # Spec D2: NOT "~/.plastic". Both executable call sites sit inside double-quoted
18
+ # shell words, where a leading tilde does not expand. $HOME does, and it is the exact
19
+ # form the install_claude hook rewrite already writes.
20
+ PLUGIN_ROOT_CODEX = "$HOME/.plastic"
21
+
22
+ # Spec D3: these files document Claude's placeholder convention TO SKILL AUTHORS.
23
+ # Rewriting the token there turns true sentences false, so the file is exempt by path.
24
+ PLUGIN_ROOT_EXEMPT = ["skill-creating/references/hooks.md"].freeze
25
+
26
+ # Spec D4: an explicit root table, not one regex. The ~/.claude lines in the skill
27
+ # corpus carry four different meanings; only these three have an exact Codex
28
+ # equivalent. ~/.claude/hooks/... is deliberately absent (D5): Codex installs no
29
+ # per-agent hook launchers, so there is no path to substitute into.
30
+ CLAUDE_ROOTS_CODEX = {
31
+ "~/.claude/skills" => "~/.agents/skills",
32
+ "~/.claude/plastic" => "~/.agents/plastic",
33
+ "~/.claude/settings.json" => "~/.codex/hooks.json",
34
+ }.freeze
35
+
36
+ def for_codex(text, rel_path:, skill_names:)
37
+ out = text
38
+ out = out.gsub(PLUGIN_ROOT_TOKEN, PLUGIN_ROOT_CODEX) unless PLUGIN_ROOT_EXEMPT.include?(rel_path)
39
+ CLAUDE_ROOTS_CODEX.each { |from, to| out = out.gsub(from, to) }
40
+ rewrite_skill_prefix(out, skill_names, "$")
41
+ end
42
+
43
+ # Spec D6. Two guards, both required:
44
+ # the negative lookbehind rejects any /plastic- that is part of a longer path
45
+ # ("../plastic-conventions/", "/tmp/plastic-{session}", "agents/plastic-*.md",
46
+ # "~/.claude/hooks/plastic-session-start");
47
+ # the name alternation, built from the real skills/ directory listing, rejects
48
+ # anything that is not an installed skill ("/plastic-lock" is a CLI script,
49
+ # "/plastic-intent-curator" is an agent role).
50
+ # Longest name first so /plastic-intent-continuing cannot match "continuing".
51
+ def rewrite_skill_prefix(text, skill_names, prefix)
52
+ return text if skill_names.nil? || skill_names.empty?
53
+ alt = skill_names.sort_by { |n| -n.length }.map { |n| Regexp.escape(n) }.join("|")
54
+ re = %r{(?<![\w./~*-])/plastic-(#{alt})(?![\w-])}
55
+ text.gsub(re) { "#{prefix}plastic-#{Regexp.last_match(1)}" }
56
+ end
57
+ end
@@ -23,7 +23,40 @@ module HookRegistry
23
23
  ].freeze
24
24
 
25
25
  WRITE_MATCHER = (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).join("|")
26
- CREATE_MATCHER = (%w[Write Edit] + SERENA_EDIT_TOOLS).join("|")
26
+
27
+ # Per-gate tool applicability for the merged edit-path dispatcher (intent 244,
28
+ # spec D-d/D-l). Registration collapses to ONE PreToolUse hook on WRITE_MATCHER
29
+ # (a strict superset of the three former matcher groups), and this table is what
30
+ # keeps each gate's own coverage exactly what it was: scripts/hook-edit-gates
31
+ # reads tool_name off the payload and skips any gate whose list excludes it.
32
+ # Key order IS the evaluation order (spec D-a): savepoint-pre first because it
33
+ # never denies and its ledger append must stay unconditional; lock-gate leads
34
+ # the deniers because holding the delivery lock is the precondition the other
35
+ # rules assume. A test pins this table against today's three matcher groups, so
36
+ # no gate's coverage can widen or narrow silently.
37
+ GATE_TOOLS = {
38
+ "savepoint-pre" => %w[Write Edit].freeze,
39
+ "lock-gate" => (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).freeze,
40
+ "code-gate" => (%w[Write Edit NotebookEdit] + SERENA_EDIT_TOOLS).freeze,
41
+ "links-gate" => %w[Write Edit].freeze,
42
+ "create-gate" => (%w[Write Edit] + SERENA_EDIT_TOOLS).freeze,
43
+ }.freeze
44
+
45
+ # Per-gate tool applicability for the merged CODEX edit-path dispatcher (intent
46
+ # 251, spec D1). Codex reports exactly one file-mutation tool name, apply_patch
47
+ # (intent 181 F4), so every value is the same single-entry list. The table is
48
+ # NOT decoration: EditGates.tool_applies? builds a regex from these values, and
49
+ # reusing GATE_TOOLS here would match none of them against "apply_patch" and
50
+ # silently skip all five gates. Key order IS the evaluation order and is the
51
+ # SAME order GATE_TOOLS uses (spec D5), so the two harnesses evaluate the five
52
+ # gates in one order, not two.
53
+ CODEX_GATE_TOOLS = {
54
+ "savepoint-pre" => %w[apply_patch].freeze,
55
+ "lock-gate" => %w[apply_patch].freeze,
56
+ "code-gate" => %w[apply_patch].freeze,
57
+ "links-gate" => %w[apply_patch].freeze,
58
+ "create-gate" => %w[apply_patch].freeze,
59
+ }.freeze
27
60
 
28
61
  # event => ordered list of { "matcher" =>, "hooks" => [{ "name" =>, "status" => }] }
29
62
  # The name is the hooks/<name> launcher; the flat install ships it as
@@ -42,23 +75,16 @@ module HookRegistry
42
75
  ] },
43
76
  ],
44
77
  "PreToolUse" => [
78
+ # ONE registered edit-path hook (intent 244, spec D-d). WRITE_MATCHER is a
79
+ # strict superset of the three matcher groups this replaces; per-gate tool
80
+ # applicability lives in GATE_TOOLS above and is read by
81
+ # scripts/hook-edit-gates, so no gate's coverage widened or narrowed.
45
82
  { "matcher" => WRITE_MATCHER, "hooks" => [
46
- { "name" => "code-gate", "status" => "Checking lifecycle gate..." },
47
- { "name" => "lock-gate", "status" => "Checking lock gate..." },
48
- ] },
49
- { "matcher" => "Write|Edit", "hooks" => [
50
- { "name" => "savepoint-pre", "status" => "Recording stage start..." },
51
- { "name" => "links-gate", "status" => "Checking Links gate..." },
52
- ] },
53
- { "matcher" => CREATE_MATCHER, "hooks" => [
54
- { "name" => "create-gate", "status" => "Checking create gate..." },
83
+ { "name" => "edit-gates", "status" => "Checking Plastic gates..." },
55
84
  ] },
56
85
  { "matcher" => "Bash", "hooks" => [
57
86
  { "name" => "bash-gate", "status" => "Checking lifecycle gate..." },
58
87
  ] },
59
- { "matcher" => "Bash|Read|Grep|Glob", "hooks" => [
60
- { "name" => "retrieval-gate", "status" => "Checking retrieval gate..." },
61
- ] },
62
88
  ],
63
89
  "PostToolUse" => [
64
90
  { "matcher" => "Write|Edit", "hooks" => [
@@ -70,7 +96,7 @@ module HookRegistry
70
96
  { "name" => "continue", "status" => "Checking for continue..." },
71
97
  { "name" => "future-intent-check", "status" => "Checking future intents..." },
72
98
  { "name" => "auto-arm", "status" => "Checking auto mode..." },
73
- { "name" => "qmd-search", "status" => "Searching QMD..." },
99
+ { "name" => "power-tools", "status" => "Checking power tools..." },
74
100
  ] },
75
101
  ],
76
102
  }
@@ -85,20 +111,25 @@ module HookRegistry
85
111
  # [{"matcher","hooks":[{"type":"command","command","statusMessage"}]}]}},
86
112
  # identical to Claude's shape, string command. Single source of truth (108 D7):
87
113
  # any drift from `events` is a bug, pinned by test.
88
- CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre links-gate create-gate].freeze
114
+ #
115
+ # Codex's apply_patch matcher carries ONE dispatcher command, edit-gates,
116
+ # which runs all five gates in-process through scripts/lib/codex_edit_gates.rb
117
+ # (intent 251), mirroring what intent 244 did for Claude. Five separately
118
+ # registered commands used to cost eight OS processes per edit (five
119
+ # top-level plus three nested run_core children); one dispatcher process
120
+ # reaches the same five gate decisions.
121
+ CODEX_PRE_HOOKS = %w[edit-gates].freeze
89
122
  CODEX_POST_HOOKS = %w[gate-check].freeze
90
123
 
91
124
  # Codex's shell-tool gate hole (intent 203): bash-gate (denies a shell write to
92
- # project code before How) and retrieval-gate (advisory, never denies) both
93
- # belong on the Bash matcher, and ONLY Bash: the official Codex hooks doc's
94
- # PreToolUse event catalog enumerates exactly Bash, apply_patch, and MCP tool
95
- # calls, and neither it nor the two prior Codex research passes (198's
96
- # official-docs research, 181's deep research) documents a discrete Read,
97
- # Grep, or Glob tool name (D3). So this does NOT copy Claude's four-name
98
- # "Bash|Read|Grep|Glob" retrieval-gate matcher; registering a tool name Codex
99
- # never reports would be dead weight that looks alive, the exact defect this
100
- # intent exists to fix.
101
- CODEX_BASH_HOOKS = %w[bash-gate retrieval-gate].freeze
125
+ # project code before How) belongs on the Bash matcher, and ONLY Bash: the
126
+ # official Codex hooks doc's PreToolUse event catalog enumerates exactly Bash,
127
+ # apply_patch, and MCP tool calls, and neither it nor the two prior Codex
128
+ # research passes (198's official-docs research, 181's deep research)
129
+ # documents a discrete Read, Grep, or Glob tool name (D3). Registering a tool
130
+ # name Codex never reports would be dead weight that looks alive, the exact
131
+ # defect this intent exists to fix.
132
+ CODEX_BASH_HOOKS = %w[bash-gate].freeze
102
133
 
103
134
  # Live-state events registered WHOLE (intent 199), unlike CODEX_PRE_HOOKS/
104
135
  # CODEX_POST_HOOKS above: Codex's SessionStart/UserPromptSubmit/PreCompact already
@@ -109,8 +140,9 @@ module HookRegistry
109
140
  CODEX_LIVE_STATE_EVENTS = %w[SessionStart UserPromptSubmit PreCompact].freeze
110
141
 
111
142
  def codex_hooks_json(dispatcher_path:)
112
- # name => statusMessage, straight from the single `events` source (A8): the
113
- # guide Part 3 hooks.json format carries a per-hook statusMessage, so emit it.
143
+ # Every Codex hook name is now a name `events` itself carries (intent 251,
144
+ # spec D9): edit-gates is Claude's own PreToolUse hook name, so there is
145
+ # nothing left to merge in from a separate status table.
114
146
  status_by_name = events.values.flatten.flat_map { |g| g["hooks"] }
115
147
  .each_with_object({}) { |h, m| m[h["name"]] = h["status"] }
116
148
  cmd = ->(name) {
@@ -120,7 +152,11 @@ module HookRegistry
120
152
  }
121
153
  # Preserve the order these hook names appear across the PreToolUse groups in `events`.
122
154
  pre_order = events["PreToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
123
- pre = (pre_order & CODEX_PRE_HOOKS).map { |n| cmd.call(n) }
155
+ # The Codex apply_patch matcher carries the same edit-gates name Claude's own
156
+ # PreToolUse group carries (intent 251, spec D9), so the intersection with
157
+ # pre_order validates the Codex gate name against the single source of truth,
158
+ # `events`, rather than against a table of Claude tool applicability.
159
+ pre = (CODEX_PRE_HOOKS & pre_order).map { |n| cmd.call(n) }
124
160
  bash = (pre_order & CODEX_BASH_HOOKS).map { |n| cmd.call(n) }
125
161
  post = CODEX_POST_HOOKS.map { |n| cmd.call(n) }
126
162