@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
|
@@ -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
|
-
|
|
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" => "
|
|
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" => "
|
|
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
|
-
|
|
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)
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
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
|
-
#
|
|
113
|
-
#
|
|
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
|
-
|
|
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
|
|