@zalom/plastic 1.14.0 → 2.0.0-alpha.1

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 (190) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/config_asks.yml +38 -0
  8. package/hooks/capture +4 -0
  9. package/hooks/check-update +12 -3
  10. package/hooks/close +5 -0
  11. package/hooks/hooks.json +9 -49
  12. package/hooks/record +4 -0
  13. package/package.json +3 -2
  14. package/scripts/agent-report +8 -9
  15. package/scripts/append-ledger +195 -0
  16. package/scripts/codex-hook +101 -95
  17. package/scripts/dashboard.rb +4 -4
  18. package/scripts/doctor.rb +106 -43
  19. package/scripts/end-intent +77 -76
  20. package/scripts/exec-worktree +11 -20
  21. package/scripts/file-session-intent +61 -0
  22. package/scripts/hook-capture +242 -0
  23. package/scripts/hook-close +34 -0
  24. package/scripts/hook-record +149 -0
  25. package/scripts/hook-session-start +95 -12
  26. package/scripts/install.rb +8 -6
  27. package/scripts/lib/agent_models.rb +1 -7
  28. package/scripts/lib/arm.rb +283 -0
  29. package/scripts/lib/backfill_intent.rb +316 -0
  30. package/scripts/lib/bridge.rb +24 -1597
  31. package/scripts/lib/doctor_core.rb +17 -88
  32. package/scripts/lib/doctor_session_ledger.rb +106 -0
  33. package/scripts/lib/exec_worktree.rb +14 -60
  34. package/scripts/lib/harness_text.rb +5 -4
  35. package/scripts/lib/hook_registry.rb +44 -107
  36. package/scripts/lib/insights.rb +3 -4
  37. package/scripts/lib/installer_core.rb +19 -30
  38. package/scripts/lib/lock.rb +6 -26
  39. package/scripts/lib/outcome_guard.rb +7 -5
  40. package/scripts/lib/project_validator.rb +30 -1
  41. package/scripts/lib/roadmap_queue.rb +3 -3
  42. package/scripts/lib/roadmap_savepoint.rb +1 -1
  43. package/scripts/lib/rule_catalog.rb +6 -3
  44. package/scripts/lib/savepoint.rb +320 -0
  45. package/scripts/lib/scaffold_intent.rb +13 -179
  46. package/scripts/lib/session_backfill.rb +184 -0
  47. package/scripts/lib/session_close.rb +84 -0
  48. package/scripts/lib/session_git.rb +469 -0
  49. package/scripts/lib/session_ledger.rb +497 -0
  50. package/scripts/lib/store_discovery.rb +1 -1
  51. package/scripts/lib/store_provisioning.rb +1 -1
  52. package/scripts/maintenance-run +7 -7
  53. package/scripts/new-intent +63 -4
  54. package/scripts/plastic-lock +94 -41
  55. package/scripts/promote-session-item +150 -0
  56. package/scripts/rebuild-graph +1 -1
  57. package/scripts/roadmap-next +2 -2
  58. package/scripts/rollback.rb +42 -3
  59. package/scripts/scaffold-intent +37 -63
  60. package/scripts/session-commit +116 -0
  61. package/scripts/spawn-preamble +5 -5
  62. package/scripts/update.rb +7 -3
  63. package/skills/agent-advisor/SKILL.md +3 -3
  64. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  65. package/skills/auto/SKILL.md +218 -317
  66. package/skills/auto/evals/evals.json +10 -10
  67. package/skills/auto/references/agent-architecture.md +102 -90
  68. package/skills/auto/references/agent-report-contract.md +4 -21
  69. package/skills/auto/references/end-tail.md +47 -53
  70. package/skills/auto/references/human-report-contract.md +8 -8
  71. package/skills/conventions/SKILL.md +1 -3
  72. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  73. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  74. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  75. package/skills/dashboard/SKILL.md +1 -1
  76. package/skills/direct/SKILL.md +66 -0
  77. package/skills/direct/references/request-signals.md +59 -0
  78. package/skills/doctor/SKILL.md +58 -11
  79. package/skills/doctor/report.md +3 -1
  80. package/skills/install/SKILL.md +11 -9
  81. package/skills/intent-continuing/SKILL.md +131 -89
  82. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  83. package/skills/intent-continuing/references/context-management.md +2 -2
  84. package/skills/intent-creating/SKILL.md +2 -2
  85. package/skills/intent-creating/references/lifecycle.md +1 -1
  86. package/skills/intent-ending/SKILL.md +28 -37
  87. package/skills/intent-ending/evals/evals.json +4 -4
  88. package/skills/intent-executing/SKILL.md +12 -49
  89. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  90. package/skills/intent-speccing/SKILL.md +108 -51
  91. package/skills/intent-speccing/evals/evals.json +5 -5
  92. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  93. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  94. package/skills/project-creating/SKILL.md +1 -1
  95. package/skills/releasing/SKILL.md +7 -8
  96. package/skills/tutorial/SKILL.md +2 -2
  97. package/skills/tutorial/references/track-1-guided.md +16 -18
  98. package/skills/tutorial/references/track-2-auto.md +12 -13
  99. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  100. package/templates/agents.md +4 -7
  101. package/templates/project.yml +7 -0
  102. package/templates/roadmap.md +1 -1
  103. package/templates/savepoint.md +2 -2
  104. package/templates/session-intent.md +29 -0
  105. package/agents/plastic-brainstorming.md +0 -48
  106. package/agents/plastic-future-intent-researcher.md +0 -35
  107. package/agents/plastic-intent-curator.md +0 -47
  108. package/agents/plastic-intent-discovery.md +0 -43
  109. package/agents/plastic-planner.md +0 -56
  110. package/agents/plastic-spec-specialist.md +0 -53
  111. package/hooks/auto-arm +0 -5
  112. package/hooks/bash-gate +0 -3
  113. package/hooks/continue +0 -31
  114. package/hooks/edit-gates +0 -3
  115. package/hooks/future-intent-check +0 -25
  116. package/hooks/gate-check +0 -12
  117. package/hooks/power-tools +0 -8
  118. package/scripts/hook-auto-arm +0 -51
  119. package/scripts/hook-bash-gate +0 -78
  120. package/scripts/hook-code-gate +0 -38
  121. package/scripts/hook-continue +0 -48
  122. package/scripts/hook-create-gate +0 -51
  123. package/scripts/hook-edit-gates +0 -58
  124. package/scripts/hook-future-intent-check +0 -90
  125. package/scripts/hook-gate-check +0 -169
  126. package/scripts/hook-links-gate +0 -54
  127. package/scripts/hook-lock-gate +0 -47
  128. package/scripts/hook-power-tools +0 -38
  129. package/scripts/hook-savepoint-pre +0 -32
  130. package/scripts/lib/codex_edit_gates.rb +0 -138
  131. package/scripts/lib/edit_gates.rb +0 -398
  132. package/scripts/lib/links_gate.rb +0 -140
  133. package/scripts/lib/qmd_hook.rb +0 -41
  134. package/scripts/lib/spec_header.rb +0 -83
  135. package/scripts/lib/start_intent.rb +0 -296
  136. package/scripts/start-intent +0 -89
  137. package/skills/_active-intent-gate.md +0 -26
  138. package/skills/auto/references/tiers.md +0 -100
  139. package/skills/continuing/SKILL.md +0 -34
  140. package/skills/continuing/evals/evals.json +0 -91
  141. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  142. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  143. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  144. package/skills/intent-brainstorming/SKILL.md +0 -118
  145. package/skills/intent-brainstorming/evals/evals.json +0 -67
  146. package/skills/intent-continuing/evals/evals.json +0 -145
  147. package/skills/intent-discovering/SKILL.md +0 -56
  148. package/skills/intent-grilling/SKILL.md +0 -108
  149. package/skills/intent-linking/SKILL.md +0 -128
  150. package/skills/intent-linking/evals/evals.json +0 -22
  151. package/skills/intent-linking/references/zettelkasten.md +0 -45
  152. package/skills/intent-locking/SKILL.md +0 -64
  153. package/skills/intent-planning/SKILL.md +0 -145
  154. package/skills/intent-planning/evals/evals.json +0 -81
  155. package/skills/intent-planning/references/plan-format.md +0 -130
  156. package/skills/intent-researching/SKILL.md +0 -123
  157. package/skills/intent-researching/evals/evals.json +0 -22
  158. package/skills/intent-savepoint/SKILL.md +0 -83
  159. package/skills/intent-savepoint/references/context-management.md +0 -32
  160. package/skills/intent-starting/SKILL.md +0 -151
  161. package/skills/intent-starting/evals/evals.json +0 -117
  162. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  163. package/skills/project-continuing/SKILL.md +0 -119
  164. package/skills/project-continuing/evals/evals.json +0 -100
  165. package/skills/roadmap-continuing/SKILL.md +0 -89
  166. package/skills/roadmap-continuing/evals/evals.json +0 -82
  167. package/skills/skill-creating/SKILL.md +0 -75
  168. package/skills/skill-creating/evals/evals.json +0 -108
  169. package/skills/skill-creating/references/agents.md +0 -168
  170. package/skills/skill-creating/references/defaults-first.md +0 -23
  171. package/skills/skill-creating/references/evals.md +0 -41
  172. package/skills/skill-creating/references/hooks.md +0 -251
  173. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  174. package/skills/skill-creating/references/scripts.md +0 -166
  175. package/skills/skill-creating/references/skills.md +0 -169
  176. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  177. package/skills/skill-evaluating/SKILL.md +0 -141
  178. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  179. package/skills/skill-evaluating/evals/evals.json +0 -75
  180. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  181. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  182. package/skills/store-curating/SKILL.md +0 -64
  183. package/skills/store-curating/evals/evals.json +0 -38
  184. package/skills/store-indexing/SKILL.md +0 -93
  185. package/skills/store-indexing/evals/evals.json +0 -22
  186. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  187. package/skills/store-provisioning/SKILL.md +0 -55
  188. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  189. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  190. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -1,301 +1,40 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: UTF-8
3
3
 
4
- require "json"
5
4
  require "yaml"
6
- require "fileutils"
7
- require "tempfile"
8
- require "digest"
9
- require "socket"
10
- require_relative "worktree"
11
5
  require_relative "lock"
12
- require_relative "spec_header"
13
6
 
7
+ # Bridge - the shared helpers that outlived the /tmp bridge JSON.
8
+ #
9
+ # Until 2.0 this file cached a session's delivery state in
10
+ # `<tmp>/plastic-<session>--<id>.json` (stage, worktree, lock, auto flag) and
11
+ # arbitrated which bridge a gate hook should read. Ruling 6 of intent 296
12
+ # retired that: the per-session pointer (`~/.plastic/store/.tmp/<session>/current`)
13
+ # plus `delivery.lock` in the intent directory is the whole bridge, and
14
+ # `scripts/lib/arm.rb` is how a team takes and gives back an intent (intent
15
+ # 307). What stays here is the handful of pure helpers a dozen callers still
16
+ # share: the INDEX entry matcher, the Active check, the id-from-dir parser,
17
+ # the project config reader, `blank?`, and the skill-reference delegator.
14
18
  module Bridge
15
- STAGES = %w[what why how exec done].freeze
16
-
17
- # Placeholder sentinel (intent 60b). A scaffolded lifecycle file
18
- # (spec.md/plan.md/checklist.md/outcome.md) carries this exact string as its
19
- # first line until an agent fills the file and deletes the sentinel. The
20
- # sentinel is the "stage not reached yet" marker, so stage detection treats a
21
- # sentinel-marked file as absent (see stage_file_present?). The intent file
22
- # (<id>--<slug>.md) is never sentineled; it is born complete.
23
- PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
24
-
25
- # Single place a skill-reference string gets built (intent 201, D3): every
26
- # message that used to write "/plastic-something" by hand calls this
27
- # instead, so a fourth harness only teaches ITS prefix once instead of
28
- # hunting the codebase for hardcoded slashes. The actual prefix table lives
29
- # on Lock (see lock.rb), which bridge.rb already requires; this is a thin
30
- # delegator so every call site in this file reads Bridge.skill_ref.
19
+ # Single place a skill-reference string gets built (intent 201, D3). The
20
+ # prefix table lives on Lock; this is a thin delegator so call sites read
21
+ # Bridge.skill_ref.
31
22
  def self.skill_ref(name, harness: :claude)
32
23
  Lock.skill_ref(name, harness: harness)
33
24
  end
34
25
 
35
- # Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
36
- # weight ONLY once its intent is terminal (no longer in its store's INDEX.md
37
- # `## Active` block); such bridges are purged. An Active intent's bridge is kept
38
- # unconditionally, because while the intent is live the bridge is still load-
39
- # bearing: it is the continuation signal (a parked or interrupted run resumes
40
- # from it) and the anti-collision lock for parallel deliveries on one store,
41
- # keeping each session's gate checks and locks from overwriting another's. An
42
- # age window was the wrong
43
- # axis: it left dead bridges resident for ~2 days AND could reap bridges of
44
- # interrupted-but-still-active intents, which are exactly the ones to preserve.
45
-
46
- def self.intent_file(intent_dir)
47
- dir_name = File.basename(intent_dir)
48
- "#{intent_dir}/#{dir_name}.md"
49
- end
50
-
51
- # Single source for the OS temp location holding bridge files. Lets every
52
- # process (arm_auto, the gate hooks) agree, and lets tests fully isolate by
53
- # pointing PLASTIC_TMP at a Dir.mktmpdir. This is OS-temp-location resolution,
54
- # not a logic-config injection seam.
55
- def self.tmp_dir
56
- t = ENV["PLASTIC_TMP"]
57
- (t.nil? || t.strip.empty?) ? "/tmp" : t
58
- end
59
-
60
- # Per-intent bridge key (intent 131): `plastic-<session>--<intent_id>.json`
61
- # when intent_id is present, else the legacy single-key
62
- # `plastic-<session>.json`. The per-intent key is what lets two concurrent
63
- # deliveries under ONE session id keep separate bridge files instead of
64
- # clobbering a shared one; the legacy form is still produced (and read) when
65
- # no intent_id is given, so old single-key files stay valid.
66
- def self.path(session, intent_id: nil, tmp: tmp_dir)
67
- if blank?(intent_id)
68
- "#{tmp}/plastic-#{session}.json"
69
- else
70
- "#{tmp}/plastic-#{session}--#{intent_id}.json"
71
- end
72
- end
73
-
74
- # --- Session resolution (intent 52) ----------------------------------------
75
-
76
26
  def self.blank?(value)
77
27
  value.nil? || value.to_s.strip.empty?
78
28
  end
79
29
 
80
- # Raised by arm when the delivery lock cannot be acquired (held elsewhere,
81
- # stale, excluded, or corrupt). The message names the resolving command.
82
- class LockHeldError < StandardError; end
83
-
84
- # The absolute intent dir a bridge points at, or nil.
85
- def self.bridge_intent_dir(bridge_data)
86
- return nil unless bridge_data.is_a?(Hash)
87
- info = bridge_data["intent"] || {}
88
- store = info["store"]
89
- dir = info["dir"]
90
- (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
91
- end
92
-
93
- # Bridge-cache copy of the durable lock file's fields (D2: the bridge is a
94
- # CACHE; the file is the truth). Never carries a pid.
95
- def self.lock_cache(lock_data)
96
- {
97
- "owner_session" => lock_data["owner_session"],
98
- "acquired_at" => lock_data["acquired_at"],
99
- "host" => lock_data["host"],
100
- "type" => lock_data["type"],
101
- "delegates" => Array(lock_data["delegates"]),
102
- "owner_harness" => lock_data["owner_harness"],
103
- "owner_agent" => lock_data["owner_agent"],
104
- "owner_model" => lock_data["owner_model"],
105
- "owner_thread" => lock_data["owner_thread"],
106
- "run_mode" => lock_data["run_mode"],
107
- "delegate_activity" => Array(lock_data["delegate_activity"]),
108
- }
109
- end
110
-
111
- # Deterministic, session-id-less bridge key derived from store + intent id.
112
- # Stable across processes so a session-less arm and a later session-less
113
- # gate-check resolve to the same bridge file.
114
- def self.derive_key(store, intent_id)
115
- "auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
116
- end
117
-
118
- # Resolve a bridge session: first non-empty of explicit (the stdin session_id),
119
- # CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
120
- # Whitespace-only counts as empty.
121
- #
122
- # The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
123
- # session id (Claude Code passes session_id on stdin, not via an env var; the
124
- # headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
125
- # a derived hash) lets the gate hooks, which receive that same id on stdin, find
126
- # the bridge by direct filename lookup.
127
- def self.resolve_session(explicit, intent_id:, store:)
128
- return explicit.to_s.strip unless blank?(explicit)
129
- code_env = ENV["CLAUDE_CODE_SESSION_ID"]
130
- return code_env.to_s.strip unless blank?(code_env)
131
- derive_key(store, intent_id)
132
- end
133
-
134
- # Walk up from file_path; return the first ancestor that looks like an intent
135
- # directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
136
- # target without needing a bridge. The input is always a file inside the intent
137
- # dir (never the dir itself), so the walk-up starts at its parent.
138
- def self.intent_dir_for(file_path)
139
- dir = File.expand_path(file_path)
140
- loop do
141
- parent = File.dirname(dir)
142
- break if parent == dir # reached filesystem root
143
- dir = parent
144
- return dir if dir.match?(%r{/store/[^/]+--[^/]+\z})
145
- end
146
- nil
147
- end
148
-
149
- # A bridge hash is usable iff it has a non-empty session and an intent Hash.
150
- def self.bridge_valid?(data)
151
- data.is_a?(Hash) && !blank?(data["session"]) && data["intent"].is_a?(Hash)
152
- end
153
-
154
- # Tiered cwd discriminator for one bridge candidate (intent 131). A session
155
- # now owns SEVERAL bridges (one per concurrent intent), so the discriminator
156
- # that used to be "cwd overlaps intent.store" is too coarse: every sibling
157
- # under the same store shares it. worktree.code is the only field that
158
- # differs between siblings, so it is the strongest signal; the intent dir is
159
- # next; the shared store is a last-resort coarse tie.
160
- # 2 - cwd is the intent's provisioned code worktree (or under it)
161
- # 1 - cwd is the intent's own dir (or under it)
162
- # 0 - cwd merely overlaps the intent's store (shared by every sibling)
163
- # -1 - no signal at all
164
- def self.bridge_cwd_tier(data, cwd_abs)
165
- worktree_code = data.dig("worktree", "code")
166
- if !blank?(worktree_code)
167
- wc_abs = File.expand_path(worktree_code)
168
- return 2 if cwd_abs == wc_abs || cwd_abs.start_with?("#{wc_abs}/")
169
- end
170
-
171
- dir_abs = bridge_intent_dir(data)
172
- if dir_abs
173
- return 1 if cwd_abs == dir_abs || cwd_abs.start_with?("#{dir_abs}/")
174
- end
175
-
176
- store = data.dig("intent", "store").to_s
177
- unless store.empty?
178
- store_abs = File.expand_path(store)
179
- return 0 if cwd_abs == store_abs || cwd_abs.start_with?("#{store_abs}/") ||
180
- store_abs.start_with?("#{cwd_abs}/")
181
- end
182
-
183
- -1
184
- end
185
-
186
- # The provisioned code worktree dir (<repo>/.claude/worktrees/{id}--{slug})
187
- # that contains file_path, or nil (intent 168). Structural and bridge-free:
188
- # Worktree.provision builds exactly this layout, so a file under it is
189
- # "worktree-scoped" and only that intent may gate it. The {id}--{slug} shape
190
- # (a `--` in the dir name) is required, so a stray .claude/worktrees/README
191
- # is not worktree-scoped and returns nil.
192
- def self.enclosing_worktree_dir(file_path)
193
- return nil if blank?(file_path)
194
- m = File.expand_path(file_path).match(%r{\A(.*/\.claude/worktrees/[^/]+--[^/]+)(?:/|\z)})
195
- m && m[1]
196
- end
197
-
198
- # Resolve the active bridge: scan tmp for plastic-*.json (both per-intent and
199
- # legacy-keyed files), keep only valid bridges, filter to the caller's own
200
- # session when it has one, prefer auto-armed, then disambiguate by cwd tier
201
- # (see bridge_cwd_tier), tie-break by newest mtime. No exact-session fast
202
- # path: a session now legitimately owns several bridges (one per concurrent
203
- # intent), so filename lookup alone cannot pick the right one; cwd must
204
- # decide (intent 131).
205
- def self.discover_bridge(session:, cwd: Dir.pwd, tmp: tmp_dir, edited_path: nil)
206
- candidates = Dir.glob(File.join(tmp, "plastic-*.json")).reject { |f| f.end_with?(".tmp") }
207
- parsed = candidates.filter_map do |f|
208
- data = (JSON.parse(File.read(f)) rescue nil)
209
- next unless data && bridge_valid?(data)
210
- { file: f, data: data, mtime: File.mtime(f) }
211
- end
212
- return nil if parsed.empty?
213
-
214
- # Worktree-membership-first (intent 168). hook-code-gate passes the edited
215
- # file as edited_path; when that file lies inside a provisioned code worktree
216
- # (<repo>/.claude/worktrees/{id}--{slug}), only that worktree's owning intent
217
- # may gate the write, BEFORE the intent-90 per-session filter below. Resolve
218
- # the candidate whose worktree.code owns that dir (newest mtime on a tie), or
219
- # nil when none owns it, so a session-keyed guided bridge can never claim a
220
- # write located inside a sibling intent's worktree. No edited_path (every
221
- # other caller) or a non-worktree path skips this and runs the pipeline
222
- # unchanged (intents 90/52/131 preserved).
223
- unless blank?(edited_path)
224
- wt_dir = enclosing_worktree_dir(edited_path)
225
- if wt_dir
226
- owners = parsed.select do |c|
227
- code = c[:data].dig("worktree", "code")
228
- !blank?(code) && File.expand_path(code) == wt_dir
229
- end
230
- return owners.max_by { |c| c[:mtime] }&.fetch(:data)
231
- end
232
- end
233
-
234
- has_session = !blank?(session)
235
-
236
- # Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
237
- # session's bridge is NEVER a valid resolution. Own-session and the derived-key case both
238
- # reduce to candidate["session"] == session (the derived key IS the session that armed the
239
- # bridge). A caller that owns no bridge resolves to nil, so its gates fail open instead of
240
- # inheriting another session's armed intent.
241
- #
242
- # When the caller has NO session (truly headless, intent 52), keep the legacy degraded
243
- # selection below so a single armed derived-key bridge is still discoverable - the hook
244
- # cannot know the session there, and a lone armed intent must still gate.
245
- if has_session
246
- parsed = parsed.select { |c| c[:data]["session"].to_s == session.to_s }
247
- return nil if parsed.empty?
248
- end
249
-
250
- # Auto-preference pool: a build-armed bridge is preferred over a merely
251
- # derived one, but ONLY as a fallback when cwd cannot decide (below). cwd
252
- # must win over auto-preference, so this pool is not applied before the
253
- # cwd tiering (intent 131: a guided sibling in the caller's own worktree
254
- # must beat an auto sibling in another worktree).
255
- auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
256
- auto_pool = auto.empty? ? parsed : auto
257
-
258
- unless blank?(cwd)
259
- cwd_abs = File.expand_path(cwd)
260
- # Tier the FULL session pool by cwd BEFORE the auto-preference filter.
261
- # When cwd overlaps ANY candidate (tier >= 0) it decides outright, even
262
- # against a newer or auto-armed sibling: worktree.code (tier 2) and the
263
- # intent dir (tier 1) disambiguate same-store siblings (intent 131), and
264
- # a store overlap (tier 0) still selects the overlapping bridge over an
265
- # off-cwd one in another store (the intent 90/52 store filter, preserved).
266
- # Only when NO candidate overlaps cwd (max tier -1) do we fall through to
267
- # the auto-preference pool and newest mtime, so a lone armed bridge
268
- # off-cwd still resolves (intent 52 headless).
269
- tiered = parsed.map { |c| [bridge_cwd_tier(c[:data], cwd_abs), c] }
270
- max_tier = tiered.map(&:first).max
271
- if max_tier && max_tier >= 0
272
- winners = tiered.select { |tier, _| tier == max_tier }.map { |_, c| c }
273
- return winners.max_by { |c| c[:mtime] }&.fetch(:data)
274
- end
275
- end
276
-
277
- auto_pool.max_by { |c| c[:mtime] }&.fetch(:data)
278
- end
279
-
280
30
  # --- Shared INDEX entry matcher (intent 188, D12/D13) -----------------------
281
31
  #
282
32
  # ONE definition site for the "- [ID <sep> Title](link)" shape both
283
33
  # `intent_active?` (below) and `scripts/end-intent`'s own INDEX-move parser
284
- # depend on, so the two regexes can never drift apart again (the em-dash-only
285
- # bug was flagged and deferred at intents 96 and 169, then independently
286
- # rediscovered as a SEPARATE end-intent defect at intent 188). Accepts a real
287
- # em dash (U+2014) OR a plain hyphen as the id/title separator on READ.
288
- # Hardening this widens intent_active?'s fail-open case: a hyphen-formatted
289
- # `## Active` line used to read as not-active (lock gate failed open); it now
290
- # reads as active (gate correctly blocks). Accepted as a bug fix (D13): no
291
- # passing test relied on the old fail-open behavior. Every WRITE still emits
292
- # the real em dash (D10); only what this matcher can PARSE has widened.
293
- #
294
- # The separator is built from the codepoint, not a literal byte in this
295
- # source file, so this new code stays em-dash free (the shipped-file
296
- # convention; store files like INDEX.md are the exempt surface this matcher
297
- # READS, not where this constant lives). Matches the existing convention in
298
- # scripts/end-intent.
34
+ # depend on, so the two regexes can never drift apart. Accepts a real em dash
35
+ # (U+2014) OR a plain hyphen as the id/title separator on READ; every WRITE
36
+ # still emits the real em dash (D10). The separator is built from the
37
+ # codepoint, not a literal byte, so this file stays em-dash free.
299
38
  EM_DASH = "\u2014".freeze
300
39
  INDEX_ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
301
40
 
@@ -305,15 +44,11 @@ module Bridge
305
44
  line.to_s.match(INDEX_ENTRY_RE)
306
45
  end
307
46
 
308
- # --- Terminal-state bridge purge (intent 80) -------------------------------
309
-
310
- # True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
311
- # the PARENT of the store/ dir the bridge records, so we resolve it from the
312
- # bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
313
- # bad arg) returns false, which means "not active" so the caller treats the
314
- # bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
315
- # Array of id strings is supplied, membership is checked against it directly
316
- # with no file read.
47
+ # True iff the intent is Active in its store's INDEX.md, which lives at the
48
+ # PARENT of the store/ dir. Non-raising: any failure (missing or unreadable
49
+ # INDEX, bad arg) returns false. `index_active_ids` is a pure-data test
50
+ # seam: when an Array of id strings is supplied, membership is checked
51
+ # against it directly with no file read.
317
52
  def self.intent_active?(intent_id, store:, index_active_ids: nil)
318
53
  target = intent_id.to_s
319
54
  return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
@@ -338,535 +73,7 @@ module Bridge
338
73
  false
339
74
  end
340
75
 
341
- # Remove tmp/plastic-*.json bridge files whose intent is terminal, so
342
- # discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
343
- # returns the array of removed paths. A bridge is purged when it cannot be
344
- # parsed, has no intent.id, has no intent.store, or its intent is not Active in
345
- # its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
346
- # anti-collision lock), and the current session's own bridge is never purged
347
- # (preserves the disarm_auto contract that it stays readable). Wired into
348
- # arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
349
- # clean at deterministic work boundaries.
350
- def self.purge_done_bridges(session:, tmp: tmp_dir)
351
- # Own-bridge predicate (intent 131): a session now legitimately owns
352
- # SEVERAL bridges (one per concurrent intent), so "current" is no longer
353
- # one filename. Skip the legacy single-key file for this session AND every
354
- # per-intent-keyed file for this session; none of the session's own live
355
- # bridges may be reaped mid-run.
356
- own_legacy_name = File.basename(path(session, tmp: tmp))
357
- own_prefix = "plastic-#{session}--"
358
- removed = []
359
- Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
360
- next if File.basename(f) == own_legacy_name || File.basename(f).start_with?(own_prefix)
361
- begin
362
- data = JSON.parse(File.read(f)) rescue nil
363
- keep = false
364
- if data
365
- id = data.dig("intent", "id")
366
- store = data.dig("intent", "store")
367
- keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
368
- # Never purge a bridge whose intent still holds a delivery lock
369
- # (intent 108, D6): the End tail clears the lock BEFORE the bridge
370
- # becomes purge-eligible, so a held lock means the tail is not done.
371
- unless keep
372
- dir = bridge_intent_dir(data)
373
- keep = !dir.nil? && File.exist?(Lock.path(dir))
374
- end
375
- end
376
- next if keep
377
- File.delete(f)
378
- removed << f
379
- rescue Errno::ENOENT
380
- # Raced with another job that already removed it; count as purged.
381
- removed << f
382
- rescue => e
383
- $stderr.puts "plastic: purge skipped #{f}: #{e.message}"
384
- end
385
- end
386
- removed
387
- rescue => e
388
- $stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
389
- removed || []
390
- end
391
-
392
- # Try the per-intent path first; when it is absent and an intent_id was
393
- # given, fall back to the legacy single-key path (migration + legacy
394
- # tolerance, intent 131): a live `plastic-<session>.json` from before this
395
- # intent keeps resolving during the transition. The legacy fallback is
396
- # honored for a specific intent_id ONLY when the legacy file actually carries
397
- # that intent (or carries none), so a caller asking for intent A never acts
398
- # on a legacy file that still holds sibling B.
399
- def self.read(session, intent_id: nil, tmp: tmp_dir)
400
- p = path(session, intent_id: intent_id, tmp: tmp)
401
- return JSON.parse(File.read(p)) if File.exist?(p)
402
- return nil if blank?(intent_id)
403
- legacy = path(session, tmp: tmp)
404
- return nil unless File.exist?(legacy)
405
- data = JSON.parse(File.read(legacy))
406
- id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
407
- (blank?(id) || id.to_s == intent_id.to_s) ? data : nil
408
- rescue JSON::ParserError
409
- nil
410
- end
411
-
412
- # Self-keying (intent 131): the file `write` targets is derived from
413
- # `data.dig("intent", "id")`, not a caller-supplied intent_id, so every
414
- # existing `write(session, data)` call site keys itself correctly for free
415
- # as long as `data["intent"]["id"]` is set (arm/derive/disarm_auto/
416
- # repair_lock/hook-gate-check/plastic-lock all carry it).
417
- def self.write(session, data, tmp: tmp_dir)
418
- raise ArgumentError, "bridge session must be present" if blank?(session)
419
- intent_id = data.is_a?(Hash) ? data.dig("intent", "id") : nil
420
- p = path(session, intent_id: intent_id, tmp: tmp)
421
- # Atomic write: tmp file + rename to prevent partial reads
422
- tmp_file = "#{p}.tmp.#{Process.pid}"
423
- File.write(tmp_file, JSON.pretty_generate(data.merge("updated_at" => Time.now.utc.iso8601)))
424
- File.rename(tmp_file, p)
425
- rescue => e
426
- File.delete(tmp_file) if tmp_file && File.exist?(tmp_file)
427
- raise e
428
- end
429
-
430
- # True iff a lifecycle file is PRESENT AND REAL: it exists and its first line is
431
- # not the placeholder sentinel. Reads only the file head (never the whole file)
432
- # so the dashboard stays fast across many intents. Exact first-line match only,
433
- # so a real file that merely contains an HTML comment later is unaffected, and a
434
- # partially-edited sentinel reads as real rather than sticking as a placeholder.
435
- def self.stage_file_present?(path)
436
- return false unless File.exist?(path)
437
- first = File.open(path, &:gets)
438
- return true if first.nil? # empty file: present, not a sentinel
439
- first.chomp != PLACEHOLDER_SENTINEL
440
- rescue StandardError
441
- File.exist?(path)
442
- end
443
-
444
- # True iff actions/ holds AT LEAST ONE real action file: a non-empty *.md whose
445
- # first line is not the placeholder sentinel. A `.gitkeep` (no .md extension)
446
- # never counts, an empty *.md never counts, and a sentinel-only *.md never
447
- # counts. Pure and side-effect-free so the gate stays unit-testable. Fail-open:
448
- # a missing actions/ dir globs to nothing and returns false (the gate then
449
- # reports it needs a real action file); it never raises.
450
- def self.has_real_action?(intent_dir)
451
- Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
452
- File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
453
- end
454
- rescue StandardError
455
- false
456
- end
457
-
458
- def self.derive_stage(intent_dir)
459
- return "done" if stage_file_present?("#{intent_dir}/outcome.md")
460
- if stage_file_present?("#{intent_dir}/plan.md") &&
461
- has_real_action?(intent_dir) &&
462
- stage_file_present?("#{intent_dir}/checklist.md")
463
- return "exec"
464
- end
465
- return "how" if stage_file_present?("#{intent_dir}/spec.md")
466
- return "why" if File.exist?(intent_file(intent_dir))
467
- "what"
468
- end
469
-
470
- def self.has_files(intent_dir)
471
- files = []
472
- ifile = File.basename(intent_file(intent_dir))
473
- files << ifile if File.exist?("#{intent_dir}/#{ifile}")
474
- ["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
475
- files << f if stage_file_present?("#{intent_dir}/#{f}")
476
- end
477
- files << "actions/" if has_real_action?(intent_dir)
478
- files
479
- end
480
-
481
- def self.missing_for_stage(stage, intent_dir = nil)
482
- ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
483
- case stage
484
- when "what" then [ifile]
485
- when "why" then ["spec.md"]
486
- when "how" then ["plan.md", "actions/", "checklist.md"]
487
- when "exec" then ["outcome.md"]
488
- else []
489
- end
490
- end
491
-
492
- # --- Gate-boundary narration (intent 84, Lever 1) -------------------------
493
- #
494
- # ONE concise sentence that states what happened AND what's next, preserving
495
- # the `Next: ...` hint the agent consumes. Pure and side-effect-free so the
496
- # hook stays a thin caller and the formatter is unit-testable in isolation.
497
- # No "Stage transition: X -> Y" prose, no arrow; a colon/parentheses carry the
498
- # stage word. Returns a single line (no embedded newlines).
499
- STAGE_LABELS = {
500
- "what" => "What", "why" => "Why", "how" => "How",
501
- "exec" => "Exec", "done" => "Done"
502
- }.freeze
503
-
504
- NEXT_HINTS = {
505
- "why" => "write spec.md",
506
- "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
507
- "exec" => "How complete. Invoke plastic-auto or plastic-intent-executing to execute, or work through the checklist manually.",
508
- "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
509
- }.freeze
510
-
511
- def self.stage_label(stage)
512
- STAGE_LABELS[stage] || stage.to_s
513
- end
514
-
515
- # Build the gate-hook `additionalContext` sentence.
516
- # transition: "PLASTIC: How reached (plan.md written). Next: <hint>"
517
- # same-stage write: "PLASTIC: plan.md written (How). Next: <hint>"
518
- # `new_missing` (missing files for the new stage) takes precedence over the
519
- # stage hint, exactly as before, so the `Next:` content is unchanged.
520
- def self.gate_narration(old_stage:, new_stage:, basename:, new_missing:, next_hints: NEXT_HINTS)
521
- head = if old_stage != new_stage
522
- "PLASTIC: #{stage_label(new_stage)} reached (#{basename} written)."
523
- else
524
- "PLASTIC: #{basename} written (#{stage_label(new_stage)})."
525
- end
526
-
527
- nxt =
528
- if Array(new_missing).any?
529
- "Next: #{Array(new_missing).join(", ")}"
530
- elsif next_hints[new_stage]
531
- "Next: #{next_hints[new_stage]}"
532
- end
533
-
534
- nxt ? "#{head} #{nxt}" : head
535
- end
536
-
537
- # --- Cycle-step savepoint ledger (intent 34) ------------------------------
538
- #
539
- # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
540
- # (newest at the bottom). It is sugar on top of the conventions: derived from
541
- # files-on-disk, rebuildable, never a source of truth. Milestones are
542
- # file-event boundaries only; action/resource files record nothing.
543
-
544
- SAVEPOINT_FILE = "savepoint.md"
545
-
546
- # Map a written filename to [stage_label, milestone_text], or nil if the file
547
- # is not a lifecycle milestone.
548
- def self.savepoint_milestone(intent_dir, basename)
549
- return ["What", basename] if basename == File.basename(intent_file(intent_dir))
550
-
551
- case basename
552
- when "spec.md" then ["Why", "spec.md created"]
553
- when "plan.md" then ["How", "plan.md created"]
554
- when "checklist.md" then ["How", "checklist.md created"]
555
- when "outcome.md" then ["Exec", "outcome.md created"]
556
- end
557
- end
558
-
559
- # Milestones already recorded in the ledger (field 3 of each line).
560
- def self.savepoint_recorded_milestones(intent_dir)
561
- f = File.join(intent_dir, SAVEPOINT_FILE)
562
- return [] unless File.exist?(f)
563
- File.read(f).each_line.map do |line|
564
- parts = line.strip.split(/\s{2,}/)
565
- parts.length >= 3 ? parts[2] : nil
566
- end.compact
567
- end
568
-
569
- # (stage, milestone) pairs already recorded in the ledger. The pair (not the
570
- # milestone text alone) is the dedup key, because state-from-ledger lines like
571
- # `Why started` and `How started` share the milestone text "started" while
572
- # being distinct events (intent 81).
573
- def self.savepoint_recorded_pairs(intent_dir)
574
- f = File.join(intent_dir, SAVEPOINT_FILE)
575
- return [] unless File.exist?(f)
576
- File.read(f).each_line.filter_map do |line|
577
- parts = line.strip.split(/\s{2,}/)
578
- parts.length >= 3 ? [parts[1], parts[2]] : nil
579
- end
580
- end
581
-
582
- # Append one ledger line for (stage, milestone) unless that pair is already
583
- # recorded. The single append primitive shared by every line class. Returns
584
- # true when a line was written, false when it was a no-op.
585
- def self.append_savepoint_line(intent_dir, stage, milestone, now)
586
- return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
587
- line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
588
- File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
589
- true
590
- end
591
-
592
- # Append the artifact-landing milestone for file_path if (and only if) it is a
593
- # milestone not already recorded. Returns true when a line was written.
594
- def self.append_savepoint(intent_dir, file_path, now: Time.now)
595
- basename = File.basename(file_path)
596
- stage, milestone = savepoint_milestone(intent_dir, basename)
597
- return false unless milestone
598
- # A sentinel-marked lifecycle file logs NO milestone (the stage is not real
599
- # yet). The intent file is never sentineled, so it still logs its What line.
600
- return false unless stage_file_present?(File.join(intent_dir, basename))
601
-
602
- append_savepoint_line(intent_dir, stage, milestone, now)
603
- end
604
-
605
- # --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
606
- #
607
- # On top of intent 34's artifact-landing milestones, the ledger gains:
608
- # - `started` lines, one per cycle stage entry (pre-stage, written by the
609
- # PreToolUse savepoint hook the moment a stage's artifact is first written);
610
- # - an `Exec started` companion emitted when checklist.md lands;
611
- # - a terminal `Done delivered|abandoned` line written by the completion path.
612
- # None of these are derivable from files on disk, so they are deliberately NOT
613
- # part of savepoint_milestone and are never regenerated by rebuild_savepoint:
614
- # a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
615
-
616
- # Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
617
- # spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
618
- # not open a stage (checklist's Exec-start is the append_exec_started companion).
619
- def self.savepoint_started_milestone(basename)
620
- case basename
621
- when "spec.md" then ["Why", "started"]
622
- when "plan.md" then ["How", "started"]
623
- end
624
- end
625
-
626
- # Append the pre-stage `started` line for file_path, iff: the basename opens a
627
- # stage, the stage is genuinely starting (its artifact is not yet a REAL file,
628
- # so a sentinel placeholder still counts as "starting"), and the pair is not
629
- # already recorded. Returns true when a line was written.
630
- def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
631
- basename = File.basename(file_path)
632
- stage, milestone = savepoint_started_milestone(basename)
633
- return false unless milestone
634
- return false if stage_file_present?(File.join(intent_dir, basename))
635
-
636
- append_savepoint_line(intent_dir, stage, milestone, now)
637
- end
638
-
639
- # Append the `Exec started` companion (emitted when checklist.md lands, in the
640
- # same PostToolUse event as the `How checklist.md created` line). Idempotent.
641
- def self.append_exec_started(intent_dir, now: Time.now)
642
- append_savepoint_line(intent_dir, "Exec", "started", now)
643
- end
644
-
645
- TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
646
-
647
- # Append the terminal bookend `Done delivered|abandoned`, written by the
648
- # completion path when an intent transfers to INDEX's Completed/Abandoned
649
- # section. Idempotent per disposition. Raises on an unknown disposition.
650
- def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
651
- unless TERMINAL_DISPOSITIONS.include?(disposition)
652
- raise ArgumentError,
653
- "disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
654
- end
655
-
656
- append_savepoint_line(intent_dir, "Done", disposition, now)
657
- end
658
-
659
- # --- Tier convenience line (intent 130, D-A) ------------------------------
660
- #
661
- # spec.md's top `Tier: S|M|L` line is the single authoritative record of an
662
- # intent's proportional-auto-sizing tier (see PLASTIC.md `## Tiers`). This
663
- # reads that line only; it never validates or enforces it (convention-only,
664
- # matching the skill and agent contracts). Returns nil when spec.md is
665
- # absent, empty, or its first line does not match, so a missing/malformed
666
- # Tier line changes nothing about existing rebuild behavior.
667
- # The grammar itself now lives in SpecHeader (scripts/lib/spec_header.rb, intent 213);
668
- # this method is a thin read on top of it.
669
- def self.savepoint_tier(intent_dir)
670
- SpecHeader.parse_file(File.join(intent_dir, "spec.md"))[:tier]
671
- end
672
-
673
- # Reconstruct the ledger from files on disk (timestamps from mtimes), in
674
- # stage order, overwriting savepoint.md. Returns the number of lines written.
675
- # When spec.md carries a Tier line, one convenience `Tier <value>` line is
676
- # echoed right after the spec.md milestone line (same mtime), so the tier
677
- # survives a rebuild without becoming a new source of truth.
678
- def self.rebuild_savepoint(intent_dir)
679
- ordered = [
680
- File.basename(intent_file(intent_dir)),
681
- "spec.md", "plan.md", "checklist.md", "outcome.md",
682
- ]
683
- lines = ordered.flat_map do |basename|
684
- path = File.join(intent_dir, basename)
685
- next [] unless stage_file_present?(path)
686
- stage, milestone = savepoint_milestone(intent_dir, basename)
687
- next [] unless milestone
688
- stamp = File.mtime(path).utc.iso8601
689
- entry = "#{stamp} #{stage} #{milestone}\n"
690
- if basename == "spec.md" && (tier = savepoint_tier(intent_dir))
691
- [entry, "#{stamp} Tier #{tier}\n"]
692
- else
693
- [entry]
694
- end
695
- end
696
- File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
697
- lines.length
698
- end
699
-
700
- # --- Phantom-line detection (intent 134) ------------------------------------
701
- #
702
- # A companion to the ledger, not a new writer: pure, disk-only, hermetic (no bridge or
703
- # session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
704
- # a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
705
- # can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
706
- # repair is `rebuild_savepoint` (live intents) or the 124a manual Done-bookend recipe
707
- # (terminal intents, human-granted only).
708
-
709
- # (stage, milestone) -> basename, for every file-landing milestone this intent_dir could have
710
- # produced (the intent file plus the four lifecycle artifacts). Reuses savepoint_milestone so
711
- # the mapping never drifts from the one the writer itself uses.
712
- def self.savepoint_file_landing_pairs(intent_dir)
713
- basenames = [File.basename(intent_file(intent_dir)), "spec.md", "plan.md", "checklist.md", "outcome.md"]
714
- basenames.each_with_object({}) do |basename, map|
715
- pair = savepoint_milestone(intent_dir, basename)
716
- map[pair] = basename if pair
717
- end
718
- end
719
-
720
- # A `started` state line's real prerequisite is the PRECEDING stage's artifact, not its own
721
- # (a `started` line legitimately fires before its own stage's file is real by design). `Exec
722
- # started` additionally requires plan.md, since Exec cannot start before How produced it too.
723
- SAVEPOINT_STATE_PREREQUISITES = {
724
- ["How", "started"] => ["spec.md"],
725
- ["Exec", "started"] => ["plan.md", "checklist.md"],
726
- }.freeze
727
-
728
- # Raw (stripped) ledger lines whose disk evidence contradicts them, each paired with a short
729
- # reason: [line, reason]. Three phantom classes (D5):
730
- # - a file-landing milestone whose file is absent or still a sentinel placeholder;
731
- # - a duplicate (stage, milestone) pair (the later occurrence is the phantom);
732
- # - a state line (`How started` / `Exec started`) whose stage prerequisites are absent.
733
- # A clean ledger, or an absent one, returns [].
734
- def self.savepoint_phantom_lines(intent_dir)
735
- path = File.join(intent_dir, SAVEPOINT_FILE)
736
- return [] unless File.exist?(path)
737
-
738
- landing = savepoint_file_landing_pairs(intent_dir)
739
- seen = []
740
- phantoms = []
741
-
742
- File.read(path).each_line do |raw|
743
- line = raw.strip
744
- next if line.empty?
745
- parts = line.split(/\s{2,}/)
746
- next if parts.length < 3
747
- pair = [parts[1], parts[2]]
748
-
749
- if seen.include?(pair)
750
- phantoms << [line, "duplicate (stage, milestone) pair"]
751
- next
752
- end
753
- seen << pair
754
-
755
- if (basename = landing[pair]) && !stage_file_present?(File.join(intent_dir, basename))
756
- phantoms << [line, "milestone file absent or still a sentinel placeholder"]
757
- next
758
- end
759
-
760
- prereqs = SAVEPOINT_STATE_PREREQUISITES[pair]
761
- if prereqs && prereqs.any? { |b| !stage_file_present?(File.join(intent_dir, b)) }
762
- phantoms << [line, "state line prerequisite absent on disk"]
763
- end
764
- end
765
-
766
- phantoms
767
- end
768
-
769
- # Pure compute (intent 230): build the bridge state and write NOTHING. `derive`
770
- # is the writing wrapper over this; `arm` and `repair_lock` use the pure form so
771
- # the single bridge write happens only after the delivery lock and the worktree
772
- # have both settled. Joins the pure `derive_stage` / `derive_key` family.
773
- def self.derive_data(session, intent_id:, intent_dir:, store:, name:)
774
- stage = derive_stage(intent_dir)
775
- has = has_files(intent_dir)
776
- missing = missing_for_stage(stage, intent_dir) - has
777
-
778
- {
779
- "session" => session,
780
- "intent" => {
781
- "id" => intent_id,
782
- "dir" => intent_dir.sub("#{store}/", ""),
783
- "store" => store,
784
- "name" => name
785
- },
786
- "build" => {
787
- "stage" => stage,
788
- "has" => has,
789
- "missing" => missing,
790
- "gate_failures" => 0,
791
- "auto" => false,
792
- "last_activity" => Time.now.utc.iso8601
793
- },
794
- "observe" => {
795
- "last_transition" => nil,
796
- "insights_count" => 0,
797
- "chain_spawned" => []
798
- },
799
- "tokens" => {
800
- "context_pct" => 0,
801
- "warning_at" => 80,
802
- "critical_at" => 90
803
- },
804
- # Worktree isolation block (intent 73c; store-worktree half retired by
805
- # intent 178). Born unprovisioned; arm_auto calls Worktree.provision to
806
- # fill it. "code" is an abs path or null.
807
- "worktree" => {
808
- "code" => nil,
809
- "code_branch" => nil,
810
- "provisioned" => false
811
- },
812
- # Delivery-lock CACHE block (intent 108, D2). The durable truth is the
813
- # delivery.lock file in the intent dir; arm fills this cache from it.
814
- "lock" => {
815
- "owner_session" => nil,
816
- "acquired_at" => nil,
817
- "host" => nil,
818
- "type" => nil,
819
- "delegates" => []
820
- }
821
- }
822
- end
823
-
824
- # Compute AND persist. Contract unchanged (intent 230 kept it deliberately):
825
- # `scripts/hook-session-start` calls this and wants the immediate write, and
826
- # test/bridge_worktree_derive_test.rb pins write-on-call.
827
- def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
828
- data = derive_data(session, intent_id: intent_id, intent_dir: intent_dir,
829
- store: store, name: name)
830
- write(session, data, tmp: tmp)
831
- data
832
- end
833
-
834
- # Gate check: returns nil if allowed, or an error message string if blocked
835
- def self.check_gate(intent_dir, file_being_written)
836
- basename = File.basename(file_being_written)
837
-
838
- case basename
839
- when "spec.md"
840
- ifile = intent_file(intent_dir)
841
- unless File.exist?(ifile) && File.read(ifile).include?("## Intent")
842
- return "Cannot start Why — What is incomplete (#{File.basename(ifile)} missing or no ## Intent)"
843
- end
844
- when "plan.md"
845
- unless stage_file_present?("#{intent_dir}/spec.md")
846
- return "Cannot start How — Why is incomplete (spec.md missing)"
847
- end
848
- when "checklist.md"
849
- unless stage_file_present?("#{intent_dir}/plan.md")
850
- return "Cannot complete How — plan.md missing"
851
- end
852
- unless has_real_action?(intent_dir)
853
- return "Cannot complete How — actions/ has no real action file (only .gitkeep or empty). " \
854
- "The planner must write at least one actions/ACTION_N.md before checklist.md. " \
855
- "See skills/intent-planning."
856
- end
857
- when "outcome.md"
858
- checklist = "#{intent_dir}/checklist.md"
859
- if stage_file_present?(checklist)
860
- content = File.read(checklist)
861
- unchecked = content.scan(/^- \[ \]/).length
862
- if unchecked > 0
863
- return "Cannot complete Exec — #{unchecked} unchecked items in checklist.md"
864
- end
865
- end
866
- end
867
-
868
- nil # no gate violation
869
- end
76
+ # --- Project config -------------------------------------------------------------
870
77
 
871
78
  PROJECT_CONFIG_DEFAULTS = {
872
79
  "governing_docs" => ["AGENTS.md"],
@@ -889,792 +96,12 @@ module Bridge
889
96
  PROJECT_CONFIG_DEFAULTS.dup
890
97
  end
891
98
 
892
- # --- Auto mode (intent 27) ---
893
-
894
- # Intent 230: freshly composed state carries worktree.code = nil, so a failed
895
- # Worktree.provision would have nothing to keep. Seed the block from the bridge
896
- # already on disk when it names a code path, so provision's keep-rule (see
897
- # Worktree.provision) can preserve it. Provision SUCCESS overwrites this with
898
- # the freshly resolved (identical) pointer, so this only matters on failure.
899
- def self.carry_prior_worktree(data, session, tmp: tmp_dir)
900
- prior = read(session, intent_id: data.dig("intent", "id"), tmp: tmp)
901
- block = prior && prior["worktree"]
902
- data["worktree"] = block if block.is_a?(Hash) && !blank?(block["code"])
903
- data
904
- end
905
- private_class_method :carry_prior_worktree
906
-
907
- # Shared arming spine (intent 96): resolve the session key, derive intent state,
908
- # set the caller-controlled auto flag, acquire the delivery lock, provision the
909
- # per-intent worktrees, persist, and purge terminal bridges. arm_auto (auto: true)
910
- # and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
911
- # behaviour stays identical across both modes. Works even when no bridge exists
912
- # yet (mid-session intent creation).
913
- def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:, harness: nil,
914
- agent: nil, model: nil, thread: nil)
915
- key = resolve_session(session, intent_id: intent_id, store: store)
916
- if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
917
- $stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
918
- end
919
- # Compute only (intent 230). Nothing reaches disk until the lock is ours and
920
- # the worktree has settled; a LockHeldError below must leave the previous
921
- # bridge exactly as it was.
922
- data = derive_data(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
923
- data["build"]["auto"] = auto
924
-
925
- # Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
926
- # intent dir. The bridge lock block is a cache of the file.
927
- intent_dir_abs = File.expand_path(intent_dir)
928
- status, lock_data = Lock.acquire(intent_dir_abs, session: key,
929
- harness: harness, agent: agent,
930
- model: model, thread: thread,
931
- run_mode: auto ? "auto" : "guided")
932
- case status
933
- when :acquired, :owned
934
- data["lock"] = lock_cache(lock_data)
935
- when :held
936
- raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
937
- "session #{lock_data && lock_data['owner_session']}; run " \
938
- "#{skill_ref('plastic-doctor', harness: harness)} check the lock status"
939
- when :stale
940
- raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
941
- "(owner #{lock_data && lock_data['owner_session']}); run " \
942
- "#{skill_ref('plastic-doctor', harness: harness)} reclaim the lock to take it " \
943
- "over with an audit"
944
- when :excluded
945
- raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
946
- "intent #{intent_id}; run #{skill_ref('plastic-doctor', harness: harness)} check " \
947
- "the lock status"
948
- when :corrupt
949
- raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
950
- "run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
951
- end
952
-
953
- carry_prior_worktree(data, key)
954
-
955
- # Provision the per-intent worktrees (mandatory code worktree for project
956
- # intents; fail-open for non-git / global-only). Never let a provision error
957
- # break arming: the lock and auto flag still matter.
958
- begin
959
- Worktree.provision(data)
960
- rescue => e
961
- $stderr.puts "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
962
- end
963
-
964
- write(key, data)
965
- purge_done_bridges(session: key)
966
- data
967
- end
968
- private_class_method :arm
969
-
970
- # Arm auto mode for a session+intent. Works even when no bridge exists yet
971
- # (mid-session intent creation). Re-derives intent state, then sets build.auto.
972
- def self.arm_auto(session, intent_id:, intent_dir:, store:, name:, harness: nil,
973
- agent: nil, model: nil, thread: nil)
974
- arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
975
- auto: true, harness: harness, agent: agent, model: model, thread: thread)
976
- end
977
-
978
- # Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
979
- # Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
980
- # Same signature as arm_auto; disarm_auto (mode-agnostic) releases a guided lock.
981
- def self.arm_guided(session, intent_id:, intent_dir:, store:, name:, harness: nil,
982
- agent: nil, model: nil, thread: nil)
983
- arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name,
984
- auto: false, harness: harness, agent: agent, model: model, thread: thread)
985
- end
986
-
987
- # Degrade path for disarm_auto when no intent_id is given (intent 131): the
988
- # session's sole per-intent bridge when there is exactly one, else the
989
- # legacy single-key file. Keeps the common single-intent auto path working
990
- # without every caller having to name the intent id explicitly. With TWO or
991
- # more per-intent bridges it refuses to guess (intent 233); see below.
992
- def self.sole_bridge_data(session, tmp: tmp_dir)
993
- matches = Dir.glob(File.join(tmp, "plastic-#{session}--*.json")).reject { |f| f.end_with?(".tmp") }
994
- if matches.length == 1
995
- data = (JSON.parse(File.read(matches.first)) rescue nil)
996
- return data if data
997
- end
998
- # Refuse to guess among siblings (intent 233): with 2+ per-intent bridges
999
- # the legacy single-key file below can carry EITHER sibling, so falling
1000
- # through would let a no-id disarm release the wrong intent's lock. A
1001
- # disarm that does nothing is recoverable; one that unlocks a live
1002
- # delivery is not. Zero matches keeps the legacy fallback (131 migration).
1003
- if matches.length > 1
1004
- $stderr.puts "plastic: session #{session} has #{matches.length} bridges; " \
1005
- "disarm needs an explicit intent_id (refusing to guess)"
1006
- return nil
1007
- end
1008
- read(session, tmp: tmp)
1009
- end
1010
-
1011
- # Disarm. No-op if no bridge exists for the session. End-tail order (D6):
1012
- # worktrees are merged/removed FIRST (the verify step is the caller's,
1013
- # before disarm), then the delivery lock is cleared, and only then does the
1014
- # bridge become purge-eligible. purge_done_bridges enforces the same order
1015
- # defensively by skipping any bridge whose intent still holds a lock.
1016
- #
1017
- # Now takes intent_id (intent 131): a session can own SEVERAL live bridges
1018
- # (one per concurrent intent), so disarm must target ONE of them. When
1019
- # intent_id is nil, degrades to the session's sole bridge (see
1020
- # sole_bridge_data) so the common single-intent path keeps working.
1021
- #
1022
- # The lock clear is conditional (intent 233): a failed release leaves the
1023
- # cached lock fields alone so the orphan stays findable.
1024
- def self.disarm_auto(session, intent_id: nil)
1025
- data = blank?(intent_id) ? sole_bridge_data(session) : read(session, intent_id: intent_id)
1026
- return nil unless data
1027
- data["build"] ||= {}
1028
- data["build"]["auto"] = false
1029
-
1030
- # Release the worktrees the matching arm provisioned (intent 73c). Non-fatal:
1031
- # a release error must not block disarming. CLEANUP (73c3) refines the
1032
- # merge-vs-remove policy on the completion/release path.
1033
- begin
1034
- Worktree.release(data)
1035
- rescue => e
1036
- $stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
1037
- end
1038
-
1039
- # Check what the release actually DID before touching the cache (intent
1040
- # 233). Blanking the cache after a failed release orphans the durable
1041
- # delivery.lock: the file stays on disk and nothing points at it any more.
1042
- # Rescue mirrors the Worktree.release rescue above: warn, never raise,
1043
- # never abort the rest of the tail (the guard fails milder than the bug).
1044
- dir = bridge_intent_dir(data)
1045
- release_status = nil
1046
- if dir
1047
- owner = data.dig("lock", "owner_session")
1048
- owner = session if blank?(owner)
1049
- begin
1050
- release_status = Lock.release(dir, session: owner)
1051
- rescue => e
1052
- release_status = :raised
1053
- $stderr.puts "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
1054
- end
1055
- end
1056
-
1057
- # Success means "no lock left on disk": :released (we deleted it), :none
1058
- # (there was none), and nil (no intent dir, so no release was attempted).
1059
- # Only :not_owner and a raised release keep the cache pointing at the lock
1060
- # so plastic-lock fix / reclaim / doctor can still find and repair it.
1061
- if release_status.nil? || release_status == :released || release_status == :none
1062
- data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
1063
- "host" => nil, "type" => nil, "delegates" => [] }
1064
- else
1065
- $stderr.puts "plastic: delivery lock NOT released for #{dir} (#{release_status}); " \
1066
- "bridge lock cache preserved for repair"
1067
- data["lock"] = {} unless data["lock"].is_a?(Hash)
1068
- end
1069
- # Diagnostic only (D5): the primary contract stays "owner_session non-nil
1070
- # after disarm means the lock was not released".
1071
- data["lock"]["release_status"] = release_status.nil? ? nil : release_status.to_s
1072
-
1073
- write(session, data)
1074
- purge_done_bridges(session: session)
1075
- data
1076
- end
1077
-
1078
- # One deterministic, idempotent repair (intent 108, D5): diagnose, remove
1079
- # faulty own-side state, rebuild the durable lock AND the bridge cache from
1080
- # disk truth for the current session. Legacy /tmp-only pid locks are
1081
- # migrated here: the delivery.lock file is created and the cache rebuilt
1082
- # without a pid. NEVER touches a fresh foreign lock (reports "held"); a
1083
- # stale foreign lock reports "stale" and is taken only by the explicit
1084
- # reclaim verb (Lock.takeover). Two entry points call this: the
1085
- # plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
1086
- def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
1087
- now: Time.now, tmp: tmp_dir, harness: nil,
1088
- agent: nil, model: nil, thread: nil, run_mode: nil,
1089
- hint_harness: nil)
1090
- key = resolve_session(session, intent_id: intent_id, store: store)
1091
- dir = File.expand_path(intent_dir)
1092
- actions = []
1093
- previous = read(key, intent_id: intent_id, tmp: tmp)
1094
- auto = !!(previous && previous.dig("build", "auto"))
1095
- derived_mode = if previous && previous.dig("build").is_a?(Hash) &&
1096
- previous["build"].key?("auto")
1097
- auto ? "auto" : "guided"
1098
- end
1099
- identity = { harness: harness, agent: agent, model: model, thread: thread,
1100
- run_mode: blank?(run_mode) ? derived_mode : run_mode.to_s }
1101
-
1102
- if Lock.corrupt?(dir)
1103
- File.delete(Lock.path(dir))
1104
- actions << "removed corrupt delivery.lock"
1105
- end
1106
-
1107
- lock = Lock.read(dir)
1108
- if lock && !Lock.authorized?(lock, key)
1109
- if Lock.fresh?(dir, now: now)
1110
- return { "status" => "held", "owner" => lock["owner_session"],
1111
- "actions" => actions, "session" => key }
1112
- end
1113
- return { "status" => "stale", "owner" => lock["owner_session"],
1114
- "actions" => actions, "session" => key,
1115
- "hint" => "run #{skill_ref('plastic-doctor', harness: hint_harness || harness)} reclaim the " \
1116
- "lock to take over with an audit" }
1117
- end
1118
-
1119
- if lock
1120
- if lock["owner_session"].to_s == key.to_s
1121
- lock_data = lock.dup
1122
- { "owner_harness" => harness, "owner_agent" => agent,
1123
- "owner_model" => model, "owner_thread" => thread,
1124
- "run_mode" => identity[:run_mode] }.each do |field, value|
1125
- lock_data[field] = value.to_s unless blank?(value)
1126
- end
1127
- Lock.write(dir, lock_data)
1128
- Lock.heartbeat(dir, session: key, now: now)
1129
- else
1130
- Lock.heartbeat(dir, session: key, now: now)
1131
- lock_data = Lock.read(dir)
1132
- end
1133
- role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
1134
- actions << "lock kept (#{role})"
1135
- else
1136
- status, lock_data = Lock.acquire(dir, session: key, now: now, **identity)
1137
- actions << "lock #{status}"
1138
- end
1139
-
1140
- data = derive_data(key, intent_id: intent_id, intent_dir: dir, store: store,
1141
- name: name)
1142
- data["build"]["auto"] = auto
1143
- data["lock"] = lock_cache(lock_data)
1144
-
1145
- carry_prior_worktree(data, key, tmp: tmp)
1146
-
1147
- # Provision the per-intent worktrees so the rebuilt bridge carries
1148
- # worktree.code (intent 136). Without it, cwd/edited-path selection has no
1149
- # key: the repaired intent loses its own code gate and a concurrent sibling
1150
- # wins the tie-break. Idempotent (reuse dir / reattach branch) and fail-open
1151
- # for non-git / global-only, exactly as `arm` does; never break the repair.
1152
- begin
1153
- Worktree.provision(data)
1154
- rescue => e
1155
- $stderr.puts "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
1156
- end
1157
-
1158
- write(key, data, tmp: tmp)
1159
- actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
1160
-
1161
- { "status" => "repaired", "actions" => actions,
1162
- "lock" => lock_data, "session" => key }
1163
- end
1164
-
1165
- # Decide whether a code edit should be blocked while auto mode is armed.
1166
- # Returns a reason string to BLOCK, or nil to ALLOW.
1167
- #
1168
- # Blocks iff: auto armed AND intent hasn't reached How (stage what/why) AND the
1169
- # target is project code — i.e. NOT under ~/.plastic and NOT inside the intent dir.
1170
- def self.code_gate_decision(bridge_data, file_path, home: Dir.home)
1171
- return nil unless bridge_data.is_a?(Hash)
1172
- build = bridge_data["build"] || {}
1173
- return nil unless build["auto"] == true
1174
-
1175
- intent_info = bridge_data["intent"] || {}
1176
- store = intent_info["store"]
1177
- dir = intent_info["dir"]
1178
- return nil unless store && dir
1179
- intent_dir_abs = File.expand_path("#{store}/#{dir}")
1180
-
1181
- # "How reached" = plan.md + checklist.md are both present AND actions/ holds at
1182
- # least one real action file. Gate by artifact presence, not the stage label
1183
- # (derive_stage returns "how" as soon as spec.md exists, before any plan). Code
1184
- # edits stay blocked until the planner has written a real action file, so an
1185
- # empty or .gitkeep-only actions/ never opens the code gate.
1186
- reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
1187
- stage_file_present?("#{intent_dir_abs}/checklist.md") &&
1188
- has_real_action?(intent_dir_abs)
1189
- return nil if reached_how
1190
-
1191
- file_abs = File.expand_path(file_path.to_s)
1192
- plastic_home = File.expand_path(File.join(home, ".plastic"))
1193
- return nil if file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
1194
- return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
1195
-
1196
- id = intent_info["id"]
1197
- "intent #{id} has not reached How — write plan.md + checklist.md and at least " \
1198
- "one real actions/ACTION_N.md before editing project code. Run plastic-auto or " \
1199
- "plastic-intent-planning first. (blocked edit: #{file_abs})"
1200
- end
1201
-
1202
- # --- Solo-mode detection (intent 128) ---------------------------------------
1203
- #
1204
- # Positive-only confirmation that exactly one session is delivering, from the
1205
- # durable delivery.lock files (never the /tmp bridge cache, D2). Used to relax
1206
- # the two ARBITRATION gates (lock_gate_decision, worktree_gate_decision) from
1207
- # a hard deny to an advisory allow when there is nothing to arbitrate.
1208
- #
1209
- # SOLO iff exactly ONE fresh delivery.lock exists across scan_roots, that
1210
- # lock's owner_session equals the resolved session, and its delegates array
1211
- # is empty. Any ambiguity (more than one fresh lock, including several under
1212
- # the SAME owner_session, which reads as parallel-in-play), a foreign owner,
1213
- # a non-empty delegates array, a blank/unresolvable session, or any error
1214
- # during the scan all return false (fail-closed direction preserved).
1215
- def self.solo_delivery?(scan_roots:, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
1216
- return false if blank?(session)
1217
-
1218
- lock_dirs = Array(scan_roots).compact.flat_map { |root|
1219
- Dir.glob(File.join(File.expand_path(root), "*", "delivery.lock"))
1220
- }.uniq.map { |lock_file| File.dirname(lock_file) }
1221
-
1222
- fresh_dirs = lock_dirs.select { |dir| Lock.fresh?(dir, ttl: ttl, now: now) }
1223
- fresh_locks = fresh_dirs.map { |dir| Lock.read(dir) }
1224
-
1225
- # A fresh-but-unreadable (corrupt) lock is real ambiguity, not an absence:
1226
- # dropping it via filter_map could leave exactly one READABLE lock and
1227
- # misconfirm solo while a second, unreadable-but-live lock is in play.
1228
- # Any unreadable fresh lock keeps this fail-closed (review finding 2).
1229
- return false if fresh_locks.any?(&:nil?)
1230
- return false unless fresh_locks.length == 1
1231
-
1232
- lock = fresh_locks.first
1233
- lock["owner_session"].to_s == session.to_s && Array(lock["delegates"]).empty?
1234
- rescue StandardError
1235
- false
1236
- end
1237
-
1238
- # One terse advisory line (no em-dashes), then ALLOW (nil). Shared by both
1239
- # arbitration gates so a relaxed deny always logs the same shape.
1240
- def self.solo_allow(id, reason)
1241
- $stderr.puts "plastic: solo delivery confirmed for intent #{id} (#{reason}); allowing"
1242
- nil
1243
- end
1244
-
1245
- # --- Fail-closed lock gate (intent 96) -------------------------------------
1246
-
1247
- # Returns a reason String to BLOCK, or nil to ALLOW. Decides from the
1248
- # durable delivery.lock in the TARGET intent dir (D2): the bridge argument
1249
- # only supplies a fallback session id, so a missing or disagreeing bridge
1250
- # never changes the verdict. Every deny names the exact resolving command
1251
- # (D5). ALLOW: non-intent paths, not-yet-active intents, and any session the
1252
- # target's lock names as owner or delegate (even when stale: a stale lock is
1253
- # still its owner's until an explicit takeover).
1254
- def self.lock_gate_decision(bridge_data, file_path, session: nil,
1255
- ttl: Lock::TTL_SECONDS, now: Time.now, home: Dir.home,
1256
- harness: :claude)
1257
- return nil if blank?(file_path)
1258
-
1259
- target_dir = intent_dir_for(file_path)
1260
- return nil unless target_dir
1261
- id = intent_id_from_dir(target_dir)
1262
- store = File.dirname(target_dir)
1263
- return nil unless id && intent_active?(id, store: store)
1264
-
1265
- sess = session
1266
- sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
1267
-
1268
- # Solo-mode detection (intent 128): scan this intent's store plus the
1269
- # global store under `home` for fresh delivery locks. Computed once; used
1270
- # at every arbitration deny below to relax a hard deny to an advisory
1271
- # allow when solo delivery is positively confirmed.
1272
- scan_roots = [store, File.join(File.expand_path(home), ".plastic", "store")]
1273
- solo = solo_delivery?(scan_roots: scan_roots, session: sess, ttl: ttl, now: now)
1274
-
1275
- lock = Lock.read(target_dir)
1276
- if lock
1277
- return nil if Lock.authorized?(lock, sess)
1278
- if Lock.fresh?(target_dir, ttl: ttl, now: now)
1279
- return solo_allow(id, "fresh delivery lock") if solo
1280
- return "intent #{id} delivery lock is held by session " \
1281
- "#{lock['owner_session']}. Back off; if you are the owner's " \
1282
- "subagent, the owner must run: plastic-lock delegate " \
1283
- "--intent-dir #{target_dir} --session <your-session-id>. " \
1284
- "Inspect with #{skill_ref('plastic-doctor', harness: harness)} check the " \
1285
- "lock status"
1286
- end
1287
- return solo_allow(id, "stale delivery lock") if solo
1288
- return "intent #{id} has a stale delivery lock (owner " \
1289
- "#{lock['owner_session']}); run #{skill_ref('plastic-doctor', harness: harness)} " \
1290
- "reclaim the lock to take it over, or " \
1291
- "#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
1292
- end
1293
- if Lock.corrupt?(target_dir)
1294
- return solo_allow(id, "unreadable delivery.lock") if solo
1295
- return "delivery.lock for intent #{id} is unreadable; run " \
1296
- "#{skill_ref('plastic-doctor', harness: harness)} fix the lock"
1297
- end
1298
- return solo_allow(id, "no delivery lock") if solo
1299
- "no delivery lock held for intent #{id}; run " \
1300
- "#{skill_ref('plastic-intent-starting', harness: harness)} to lock and begin"
1301
- end
1302
-
1303
- # A session holds an intent's lock iff the durable delivery.lock in the
1304
- # intent dir names it as owner or delegate (D1/D4). The bridge is only a
1305
- # cache: the lock FILE decides, so a wiped /tmp or a clobbered bridge never
1306
- # strands the owner. No pid is consulted anywhere.
1307
- def self.holds_live_lock?(bridge_data, session: nil)
1308
- sess = session
1309
- sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
1310
- return false if blank?(sess)
1311
- dir = bridge_intent_dir(bridge_data)
1312
- return false unless dir
1313
- Lock.holds?(dir, session: sess)
1314
- end
1315
-
1316
99
  # "<id>" from a ".../store/<id>--<slug>" dir, else nil.
1317
100
  def self.intent_id_from_dir(dir)
1318
101
  base = File.basename(dir.to_s)
1319
102
  base.include?("--") ? base.split("--", 2).first : nil
1320
103
  end
1321
104
 
1322
- # --- Worktree isolation gate (intent 73c2) ---
1323
-
1324
- # Returns a reason String to BLOCK, or nil to ALLOW. Two independent rules,
1325
- # both fail-open by construction:
1326
- #
1327
- # 1. When the bridge has a provisioned code worktree, a code edit (a target
1328
- # outside ~/.plastic and outside this intent's store dir) MUST land inside
1329
- # worktree["code"]; otherwise BLOCK and name the expected worktree path.
1330
- # 2. When the target lives inside ANOTHER intent's store dir whose bridge lock
1331
- # is held by a LIVE non-owner session, BLOCK (non-owner edit to an active
1332
- # intent).
1333
- #
1334
- # Fails open (returns nil) when provisioned is false (non-git / global-only) or
1335
- # the bridge carries no worktree/lock blocks. Logs nothing on the allow path.
1336
- def self.worktree_gate_decision(bridge_data, file_path, home: Dir.home, current_session: nil)
1337
- return nil unless bridge_data.is_a?(Hash)
1338
- return nil if blank?(file_path)
1339
-
1340
- file_abs = File.expand_path(file_path.to_s)
1341
- plastic_home = File.expand_path(File.join(home, ".plastic"))
1342
- under_plastic = file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
1343
-
1344
- intent_info = bridge_data["intent"] || {}
1345
- store = intent_info["store"]
1346
- dir = intent_info["dir"]
1347
- intent_dir_abs = (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
1348
- under_own_intent = intent_dir_abs &&
1349
- (file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
1350
-
1351
- # Solo-mode detection (intent 128): current session first, else the
1352
- # bridge's own session; scan roots are this intent's store, the global
1353
- # store under `home`, AND the EDIT TARGET's own store (when the target
1354
- # lives inside a store dir), so a live foreign lock on the intent being
1355
- # edited is never invisible to the scan just because it belongs to a
1356
- # different project than the acting bridge's own store (review finding 1;
1357
- # duplicate roots are harmless, solo_delivery? dedupes). Computed once;
1358
- # used by both rules below.
1359
- sess = blank?(current_session) ? bridge_data["session"] : current_session
1360
- target_store = parse_store_target(file_abs, plastic_home)&.fetch(:store, nil)
1361
- scan_roots = [store, File.join(plastic_home, "store"), target_store]
1362
- solo = solo_delivery?(scan_roots: scan_roots, session: sess)
1363
-
1364
- # Rule 1 (fixed in intent 108, D7): confinement applies ONLY to paths
1365
- # inside the project repo. The repo root is derived from the provisioned
1366
- # code worktree path, which is <repo>/.claude/worktrees/{id}--{slug} by
1367
- # construction, so no git call is needed. Paths outside the repo (agent
1368
- # memory dirs, scratch files, unrelated checkouts) are not this gate's
1369
- # business; the 2026-07-02 memory-dir denial came from treating everything
1370
- # outside the worktree as the shared checkout.
1371
- worktree = bridge_data["worktree"] || {}
1372
- if worktree["provisioned"] == true
1373
- code = worktree["code"].to_s
1374
- if !blank?(code) && !under_plastic && !under_own_intent
1375
- code_abs = File.expand_path(code)
1376
- repo_abs = File.expand_path(File.join(code_abs, "..", "..", ".."))
1377
- inside_repo = file_abs == repo_abs || file_abs.start_with?("#{repo_abs}/")
1378
- inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
1379
- if inside_repo && !inside_code
1380
- id = intent_info["id"]
1381
- return solo_allow(id, "worktree confinement") if solo
1382
- return "intent #{id} is isolated to its worktree - edit project code " \
1383
- "inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
1384
- end
1385
- end
1386
- end
1387
-
1388
- # Rule 2: do not edit another intent's locked, live store dir.
1389
- if under_plastic
1390
- reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
1391
- home: home, current_session: current_session,
1392
- own_session: bridge_data["session"])
1393
- if reason
1394
- return solo_allow(intent_info["id"], "non-owner store edit") if solo
1395
- return reason
1396
- end
1397
- end
1398
-
1399
- nil
1400
- end
1401
-
1402
- # Helper for rule 2. A store dir is `<plastic_home>/store/{id}--{slug}` (global)
1403
- # or `<plastic_home>/projects/{slug}/store/{id}--{slug}` (project). When the
1404
- # edit target sits inside such a dir that is NOT this intent's own dir, and a
1405
- # live non-owner session holds that intent's bridge lock, BLOCK.
1406
- def self.non_owner_store_edit_reason(file_abs, plastic_home, own_intent_dir_abs,
1407
- home:, current_session:, own_session:)
1408
- return nil if own_intent_dir_abs &&
1409
- (file_abs == own_intent_dir_abs || file_abs.start_with?("#{own_intent_dir_abs}/"))
1410
-
1411
- parsed = parse_store_target(file_abs, plastic_home)
1412
- return nil unless parsed
1413
-
1414
- session = blank?(current_session) ? own_session : current_session
1415
- held = Worktree.lock_held_by_other?(
1416
- intent_id: parsed[:id], store: parsed[:store],
1417
- current_session: session, home: home,
1418
- )
1419
- return nil unless held
1420
-
1421
- "intent #{parsed[:id]} is owned by another live session — its delivery lock " \
1422
- "is held elsewhere. Back off; do not edit #{file_abs}."
1423
- end
1424
-
1425
- # Resolve an edit target inside a store to {id:, store:} for the intent dir it
1426
- # belongs to, or nil if the path is not inside an `{id}--{slug}` intent dir.
1427
- def self.parse_store_target(file_abs, plastic_home)
1428
- rels = []
1429
- global_store = File.join(plastic_home, "store")
1430
- if file_abs.start_with?("#{global_store}/")
1431
- rels << [file_abs[(global_store.length + 1)..], global_store]
1432
- end
1433
- projects = File.join(plastic_home, "projects")
1434
- if file_abs.start_with?("#{projects}/")
1435
- tail = file_abs[(projects.length + 1)..].to_s
1436
- parts = tail.split(File::SEPARATOR)
1437
- if parts.length >= 2 && parts[1] == "store"
1438
- pstore = File.join(projects, parts[0], "store")
1439
- rels << [file_abs[(pstore.length + 1)..], pstore]
1440
- end
1441
- end
1442
-
1443
- rels.each do |rel, store_dir|
1444
- next if blank?(rel)
1445
- first = rel.split(File::SEPARATOR).first.to_s
1446
- idx = first.index("--")
1447
- next unless idx && idx > 0
1448
- return { id: first[0...idx], store: store_dir }
1449
- end
1450
- nil
1451
- end
1452
-
1453
- # --- Bash-edit gate (intent 27a) ---
1454
-
1455
- # Extract the set of file paths a Bash command writes to. Conservative by
1456
- # design: it is acceptable to miss exotic forms, but it must NOT flag reads
1457
- # or /dev/null. Returns an Array of path strings (possibly relative).
1458
- #
1459
- # Covered write vectors: redirection (>, >>, including heredoc `cat > f <<EOF`),
1460
- # tee / tee -a, sed -i / sed -i.bak, cp/mv (last non-flag arg), dd of=.
1461
- def self.bash_write_targets(command)
1462
- return [] unless command.is_a?(String)
1463
-
1464
- targets = []
1465
- targets.concat(bash_redirect_targets(command))
1466
- # Split on command separators for per-segment utility parsing.
1467
- command.split(/[;\n]|&&|\|\||\|/).each do |segment|
1468
- targets.concat(bash_utility_targets(segment))
1469
- targets.concat(interpreter_write_targets(segment))
1470
- end
1471
- targets.uniq
1472
- end
1473
-
1474
- # Redirections: `> path` / `>> path`, but not fd dups (`2>&1`) or /dev/null.
1475
- # A leading digit (fd number) before > is fine; `>&` is a dup and excluded.
1476
- # Quote- and heredoc-aware: a `>` inside a single/double-quoted span or inside a
1477
- # heredoc body is NOT a redirect. Fails OPEN (returns []) on an ambiguous parse
1478
- # (unbalanced quote or unterminated heredoc) rather than guessing a target.
1479
- def self.bash_redirect_targets(command)
1480
- return [] unless command.is_a?(String)
1481
- scannable = scannable_redirect_text(command)
1482
- return [] if scannable.nil? # ambiguous parse -> fail open
1483
- targets = []
1484
- scannable.scan(/\d*>>?(?!&)\s*([^\s;|&<>]+)/) do |m|
1485
- path = m[0]
1486
- next if path.nil? || path.empty?
1487
- next if dev_null?(path)
1488
- targets << path
1489
- end
1490
- targets
1491
- end
1492
-
1493
- # Return a copy of `command` in which single-quoted spans, double-quoted spans,
1494
- # and heredoc bodies are blanked to spaces, so the redirect regex only ever sees
1495
- # operators that are genuinely outside quotes and heredoc bodies. Returns nil on
1496
- # an ambiguous parse (a line ends inside a quote, or a heredoc is never closed).
1497
- def self.scannable_redirect_text(command)
1498
- out = +""
1499
- pending = [] # queue of {word:, dash:} heredoc terminators awaiting bodies
1500
- command.split("\n", -1).each do |line|
1501
- if pending.any?
1502
- term = pending.first
1503
- probe = term[:dash] ? line.sub(/\A\t+/, "") : line
1504
- pending.shift if probe == term[:word]
1505
- out << "\n" # heredoc body/terminator line contributes nothing scannable
1506
- next
1507
- end
1508
- masked, openers, balanced = mask_redirect_line(line)
1509
- return nil unless balanced # unbalanced quote on this line -> ambiguous
1510
- out << masked << "\n"
1511
- pending.concat(openers)
1512
- end
1513
- return nil if pending.any? # unterminated heredoc -> ambiguous
1514
- out
1515
- end
1516
-
1517
- # Walk one normal (non-heredoc-body) line, masking quoted spans to spaces and
1518
- # recognizing heredoc openers. Returns [masked_line, [heredoc_openers], balanced?].
1519
- def self.mask_redirect_line(line)
1520
- out = +""
1521
- openers = []
1522
- state = :normal
1523
- i = 0
1524
- n = line.length
1525
- while i < n
1526
- c = line[i]
1527
- case state
1528
- when :single
1529
- if c == "'" || c == "<" || c == ">"
1530
- out << " "
1531
- else
1532
- out << c
1533
- end
1534
- state = :normal if c == "'"
1535
- i += 1
1536
- when :double
1537
- if c == "\\" && i + 1 < n
1538
- out << " "
1539
- i += 2
1540
- else
1541
- if c == '"' || c == "<" || c == ">"
1542
- out << " "
1543
- else
1544
- out << c
1545
- end
1546
- state = :normal if c == '"'
1547
- i += 1
1548
- end
1549
- else # :normal
1550
- if c == "'"
1551
- out << " "; state = :single; i += 1
1552
- elsif c == '"'
1553
- out << " "; state = :double; i += 1
1554
- elsif c == "<" && line[i + 1] == "<"
1555
- m = line[i..].match(/\A<<(-?)\s*("|')?([A-Za-z0-9_][A-Za-z0-9_]*)\2?/)
1556
- if m
1557
- openers << { word: m[3], dash: m[1] == "-" }
1558
- out << (" " * m[0].length)
1559
- i += m[0].length
1560
- else
1561
- out << "<<"; i += 2 # here-string / no valid word: leave as-is
1562
- end
1563
- else
1564
- out << c; i += 1
1565
- end
1566
- end
1567
- end
1568
- [out, openers, state == :normal]
1569
- end
1570
-
1571
- def self.bash_utility_targets(segment)
1572
- tokens = segment.strip.split(/\s+/)
1573
- return [] if tokens.empty?
1574
-
1575
- # Find the utility name, skipping env-style assignments.
1576
- idx = 0
1577
- idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /^-/ && !tokens[idx].start_with?("of=")
1578
- util = File.basename(tokens[idx].to_s)
1579
- args = tokens[(idx + 1)..] || []
1580
-
1581
- case util
1582
- when "tee"
1583
- tee_targets(args)
1584
- when "sed"
1585
- sed_targets(args)
1586
- when "cp", "mv"
1587
- copy_move_targets(args)
1588
- when "dd"
1589
- dd_targets(tokens)
1590
- else
1591
- []
1592
- end
1593
- end
1594
-
1595
- def self.tee_targets(args)
1596
- args.reject { |a| a.start_with?("-") || dev_null?(a) }
1597
- end
1598
-
1599
- def self.sed_targets(args)
1600
- # In-place only: -i or -i.bak (suffix attached). Otherwise sed reads.
1601
- inplace = args.any? { |a| a == "-i" || a.start_with?("-i") }
1602
- return [] unless inplace
1603
- files = args.reject { |a| a.start_with?("-") }
1604
- # sed args: script then file(s). First non-flag is the script expression
1605
- # unless an -e/-f was used; conservatively treat the LAST non-flag as file.
1606
- files.empty? ? [] : [files.last].reject { |f| dev_null?(f) }
1607
- end
1608
-
1609
- def self.copy_move_targets(args)
1610
- files = args.reject { |a| a.start_with?("-") }
1611
- return [] if files.length < 2
1612
- dest = files.last
1613
- dev_null?(dest) ? [] : [dest]
1614
- end
1615
-
1616
- def self.dd_targets(tokens)
1617
- tokens.each_with_object([]) do |t, acc|
1618
- next unless t.start_with?("of=")
1619
- path = t.sub("of=", "")
1620
- acc << path unless path.empty? || dev_null?(path)
1621
- end
1622
- end
1623
-
1624
- def self.dev_null?(path)
1625
- path == "/dev/null" || path.start_with?("/dev/")
1626
- end
1627
-
1628
- # --- Interpreter inline-code writes (intent 108, D7) ---
1629
-
1630
- INTERPRETER_RE = /\b(ruby|python3?|perl|node)\b(?:\s+\S+)*?\s+(-e|-c)\s+(.+)\z/m.freeze
1631
-
1632
- # Write verbs that mark inline code as file-mutating. Conservative: reads
1633
- # (File.read, puts) never match.
1634
- WRITE_VERB_RE = /File\.(?:write|binwrite|open)|IO\.write|FileUtils\.|
1635
- open\s*\([^)]*["'][wa]|writeFileSync|fs\.write/x.freeze
1636
-
1637
- # Quoted absolute or ~/ paths inside the inline code.
1638
- INLINE_PATH_RE = %r{["']((?:/|~/)[^"']+)["']}.freeze
1639
-
1640
- # Paths an interpreter one-liner writes. Flagged only when the inline code
1641
- # has BOTH a write verb AND a quoted absolute path; everything else (reads,
1642
- # ARGV-driven paths, the sanctioned arm one-liners) yields no targets.
1643
- def self.interpreter_write_targets(segment)
1644
- m = INTERPRETER_RE.match(segment.to_s)
1645
- return [] unless m
1646
- util, flag, code = m[1], m[2], m[3]
1647
- expected = { "ruby" => "-e", "python" => "-c", "python3" => "-c",
1648
- "perl" => "-e", "node" => "-e" }[util]
1649
- return [] unless flag == expected
1650
- return [] unless WRITE_VERB_RE.match?(code)
1651
- code.scan(INLINE_PATH_RE).flatten.map { |p| File.expand_path(p) }
1652
- end
1653
-
1654
- # Decide whether a Bash command should be blocked. Every write target runs
1655
- # through the SAME policy stack as a direct tool write: the auto-mode code
1656
- # gate AND the delivery-lock gate (intent 108, D7), so bash and interpreter
1657
- # writes cannot bypass the lock. Returns the first block reason, or nil.
1658
- def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home, session: nil)
1659
- bash_write_targets(command).each do |target|
1660
- abs = File.absolute_path?(target) ? target : File.join(cwd, target)
1661
- abs = File.expand_path(abs)
1662
- reason = code_gate_decision(bridge_data, abs, home: home) ||
1663
- lock_gate_decision(bridge_data, abs, session: session)
1664
- return reason if reason
1665
- end
1666
- nil
1667
- end
1668
-
1669
- # A TRAILING `# plastic-ok` shell comment: the auditable escape for
1670
- # sanctioned bash/interpreter writes. The hook logs every use to
1671
- # ~/.plastic/.cache/gate-escapes.log.
1672
- PLASTIC_OK_RE = /(?:\A|\s)#\s*plastic-ok\s*\z/.freeze
1673
-
1674
- def self.bash_escape?(command)
1675
- PLASTIC_OK_RE.match?(command.to_s.chomp)
1676
- end
1677
-
1678
105
  def self.deep_merge(base, overlay)
1679
106
  result = base.dup
1680
107
  overlay.each do |key, value|