@zalom/plastic 1.0.0-beta.3 → 1.0.0-beta.30

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 (102) hide show
  1. package/PLASTIC.md +293 -7
  2. package/README.md +10 -0
  3. package/agents/plastic-brainstorming.md +2 -2
  4. package/agents/plastic-enforcer.md +17 -2
  5. package/agents/plastic-executor.md +3 -3
  6. package/agents/plastic-future-intent-researcher.md +2 -1
  7. package/agents/plastic-intent-curator.md +15 -7
  8. package/agents/plastic-intent-discovery.md +37 -0
  9. package/agents/plastic-planner.md +2 -2
  10. package/agents/plastic-spec-specialist.md +2 -2
  11. package/hooks/code-gate +10 -1
  12. package/hooks/hooks.json +28 -3
  13. package/hooks/lock-gate +21 -0
  14. package/hooks/retrieval-gate +10 -0
  15. package/hooks/savepoint-pre +10 -0
  16. package/hooks/statusline +150 -41
  17. package/package.json +1 -1
  18. package/scripts/agent-report +21 -0
  19. package/scripts/doctor.rb +404 -5
  20. package/scripts/hook-auto-arm +1 -1
  21. package/scripts/hook-bash-gate +22 -4
  22. package/scripts/hook-code-gate +11 -6
  23. package/scripts/hook-create-gate +45 -9
  24. package/scripts/hook-gate-check +24 -23
  25. package/scripts/hook-lock-gate +83 -0
  26. package/scripts/hook-retrieval-gate +148 -0
  27. package/scripts/hook-savepoint-pre +32 -0
  28. package/scripts/hook-session-start +1 -1
  29. package/scripts/insight-append +51 -0
  30. package/scripts/lib/agent_models.rb +43 -0
  31. package/scripts/lib/bridge.rb +625 -47
  32. package/scripts/lib/frontmatter_writer.rb +130 -0
  33. package/scripts/lib/graph_rebuild.rb +328 -0
  34. package/scripts/lib/hook_registry.rb +95 -0
  35. package/scripts/lib/insights.rb +86 -0
  36. package/scripts/lib/installer_core.rb +64 -54
  37. package/scripts/lib/link_suggestions.rb +319 -0
  38. package/scripts/lib/links_projection.rb +160 -0
  39. package/scripts/lib/links_section.rb +207 -0
  40. package/scripts/lib/lock.rb +375 -0
  41. package/scripts/lib/power_tools.rb +75 -0
  42. package/scripts/lib/qmd_hook.rb +38 -25
  43. package/scripts/lib/qmd_sync.rb +36 -0
  44. package/scripts/lib/retrieval_gate.rb +211 -0
  45. package/scripts/lib/worktree.rb +384 -0
  46. package/scripts/link-suggest +213 -0
  47. package/scripts/new-intent +138 -29
  48. package/scripts/plastic-lock +164 -0
  49. package/scripts/project-links +287 -0
  50. package/scripts/qmd-sync +50 -3
  51. package/scripts/read-config +4 -0
  52. package/scripts/rebuild-graph +244 -0
  53. package/scripts/spawn-preamble +11 -3
  54. package/skills/auto/SKILL.md +98 -16
  55. package/skills/auto/evals/evals.json +48 -0
  56. package/skills/auto/references/agent-architecture.md +7 -4
  57. package/skills/auto/references/agent-report-contract.md +36 -1
  58. package/skills/brainstorming/SKILL.md +1 -0
  59. package/skills/brainstorming/evals/evals.json +22 -0
  60. package/skills/continuing/SKILL.md +30 -8
  61. package/skills/continuing/evals/evals.json +9 -0
  62. package/skills/creating-intent/SKILL.md +16 -2
  63. package/skills/creating-intent/evals/evals.json +16 -0
  64. package/skills/creating-intent/references/lifecycle.md +9 -4
  65. package/skills/creating-skills/SKILL.md +65 -0
  66. package/skills/creating-skills/evals/evals.json +108 -0
  67. package/skills/creating-skills/references/agents.md +168 -0
  68. package/skills/creating-skills/references/evals.md +41 -0
  69. package/skills/creating-skills/references/hooks.md +248 -0
  70. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  71. package/skills/creating-skills/references/scripts.md +166 -0
  72. package/skills/creating-skills/references/skills.md +165 -0
  73. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  74. package/skills/dashboard/SKILL.md +5 -0
  75. package/skills/dashboard/evals/evals.json +22 -0
  76. package/skills/doctor/SKILL.md +4 -1
  77. package/skills/executing-plan/SKILL.md +4 -4
  78. package/skills/humanizer/SKILL.md +39 -0
  79. package/skills/humanizer/evals/evals.json +70 -0
  80. package/skills/humanizer/references/always-on-snippet.md +9 -0
  81. package/skills/humanizer/references/examples.md +48 -0
  82. package/skills/intent-curator/SKILL.md +7 -1
  83. package/skills/intent-curator/evals/evals.json +22 -0
  84. package/skills/intent-discovery/SKILL.md +46 -0
  85. package/skills/intent-starting/SKILL.md +127 -0
  86. package/skills/intent-starting/evals/evals.json +117 -0
  87. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  88. package/skills/linking-intents/SKILL.md +54 -12
  89. package/skills/linking-intents/evals/evals.json +22 -0
  90. package/skills/linking-intents/references/zettelkasten.md +7 -0
  91. package/skills/lock/SKILL.md +41 -0
  92. package/skills/managing-index/SKILL.md +12 -0
  93. package/skills/managing-index/evals/evals.json +22 -0
  94. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  95. package/skills/releasing/SKILL.md +32 -0
  96. package/skills/research/SKILL.md +8 -0
  97. package/skills/research/evals/evals.json +22 -0
  98. package/templates/config.yml +8 -0
  99. package/templates/outcome.md +3 -0
  100. package/templates/revisions.md +58 -0
  101. package/skills/writing-instructions/SKILL.md +0 -159
  102. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -6,6 +6,9 @@ require "yaml"
6
6
  require "fileutils"
7
7
  require "tempfile"
8
8
  require "digest"
9
+ require "socket"
10
+ require_relative "worktree"
11
+ require_relative "lock"
9
12
 
10
13
  module Bridge
11
14
  STAGES = %w[what why how exec done].freeze
@@ -18,13 +21,15 @@ module Bridge
18
21
  # (<id>--<slug>.md) is never sentineled; it is born complete.
19
22
  PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
20
23
 
21
- # Stale-bridge purge window (intent 67). The bridge file is ephemeral
22
- # live-session gate state, NOT a continuation source: an intent is resumed from
23
- # its savepoint.md ledger, never from a /tmp bridge. So any bridge older than
24
- # this window is dead weight and safe to purge, regardless of arm state. No
25
- # real session stays live for two days, so a 48h cutoff never removes a bridge
26
- # an active run depends on.
27
- PURGE_AGE_SECONDS = 48 * 3600 # 48 hours
24
+ # Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
25
+ # weight ONLY once its intent is terminal (no longer in its store's INDEX.md
26
+ # `## Active` block); such bridges are purged. An Active intent's bridge is kept
27
+ # unconditionally, because while the intent is live the bridge is still load-
28
+ # bearing: it is the continuation signal (a parked or interrupted run resumes
29
+ # from it) and the anti-collision lock (it keys the per-session statusline so
30
+ # parallel sessions do not overwrite each other). An age window was the wrong
31
+ # axis: it left dead bridges resident for ~2 days AND could reap bridges of
32
+ # interrupted-but-still-active intents, which are exactly the ones to preserve.
28
33
 
29
34
  def self.intent_file(intent_dir)
30
35
  dir_name = File.basename(intent_dir)
@@ -50,6 +55,31 @@ module Bridge
50
55
  value.nil? || value.to_s.strip.empty?
51
56
  end
52
57
 
58
+ # Raised by arm when the delivery lock cannot be acquired (held elsewhere,
59
+ # stale, excluded, or corrupt). The message names the resolving command.
60
+ class LockHeldError < StandardError; end
61
+
62
+ # The absolute intent dir a bridge points at, or nil.
63
+ def self.bridge_intent_dir(bridge_data)
64
+ return nil unless bridge_data.is_a?(Hash)
65
+ info = bridge_data["intent"] || {}
66
+ store = info["store"]
67
+ dir = info["dir"]
68
+ (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
69
+ end
70
+
71
+ # Bridge-cache copy of the durable lock file's fields (D2: the bridge is a
72
+ # CACHE; the file is the truth). Never carries a pid.
73
+ def self.lock_cache(lock_data)
74
+ {
75
+ "owner_session" => lock_data["owner_session"],
76
+ "acquired_at" => lock_data["acquired_at"],
77
+ "host" => lock_data["host"],
78
+ "type" => lock_data["type"],
79
+ "delegates" => Array(lock_data["delegates"]),
80
+ }
81
+ end
82
+
53
83
  # Deterministic, session-id-less bridge key derived from store + intent id.
54
84
  # Stable across processes so a session-less arm and a later session-less
55
85
  # gate-check resolve to the same bridge file.
@@ -57,12 +87,19 @@ module Bridge
57
87
  "auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
58
88
  end
59
89
 
60
- # Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
61
- # then a derived key. Never returns nil/empty. Whitespace-only counts as empty.
90
+ # Resolve a bridge session: first non-empty of explicit (the stdin session_id),
91
+ # CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
92
+ # Whitespace-only counts as empty.
93
+ #
94
+ # The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
95
+ # session id (Claude Code passes session_id on stdin, not via an env var; the
96
+ # headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
97
+ # a derived hash) lets the statusline, which receives that same id on stdin, find
98
+ # the bridge by direct filename lookup.
62
99
  def self.resolve_session(explicit, intent_id:, store:)
63
100
  return explicit.to_s.strip unless blank?(explicit)
64
- env = ENV["CLAUDE_SESSION_ID"]
65
- return env.to_s.strip unless blank?(env)
101
+ code_env = ENV["CLAUDE_CODE_SESSION_ID"]
102
+ return code_env.to_s.strip unless blank?(code_env)
66
103
  derive_key(store, intent_id)
67
104
  end
68
105
 
@@ -103,6 +140,22 @@ module Bridge
103
140
  end
104
141
  return nil if parsed.empty?
105
142
 
143
+ has_session = !blank?(session)
144
+
145
+ # Strict per-session ownership (intent 90): when the caller HAS a session, a foreign
146
+ # session's bridge is NEVER a valid resolution. Own-session and the derived-key case both
147
+ # reduce to candidate["session"] == session (the derived key IS the session that armed the
148
+ # bridge). A caller that owns no bridge resolves to nil, so its gates fail open instead of
149
+ # inheriting another session's armed intent.
150
+ #
151
+ # When the caller has NO session (truly headless, intent 52), keep the legacy degraded
152
+ # selection below so a single armed derived-key bridge is still discoverable - the hook
153
+ # cannot know the session there, and a lone armed intent must still gate.
154
+ if has_session
155
+ parsed = parsed.select { |c| c[:data]["session"].to_s == session.to_s }
156
+ return nil if parsed.empty?
157
+ end
158
+
106
159
  auto = parsed.select { |c| c[:data].dig("build", "auto") == true }
107
160
  pool = auto.empty? ? parsed : auto
108
161
 
@@ -116,31 +169,78 @@ module Bridge
116
169
  cwd_abs.start_with?("#{store_abs}/") ||
117
170
  store_abs.start_with?("#{cwd_abs}/")
118
171
  end
119
- pool = matching unless matching.empty?
172
+ # Hard cwd filter when the caller has a session (intent 90): a non-matching store
173
+ # excludes the candidate outright. Without a session, keep the best-effort revert
174
+ # (intent 52) so a lone armed bridge is still found when cwd does not overlap its store.
175
+ pool = has_session ? matching : (matching.empty? ? pool : matching)
120
176
  end
121
177
 
122
178
  pool.max_by { |c| c[:mtime] }&.fetch(:data)
123
179
  end
124
180
 
125
- # --- Stale-bridge purge (intent 67) ---------------------------------------
126
- #
127
- # Remove stale tmp/plastic-*.json bridge files so discover_bridge's per-fire
128
- # scan stays bounded. Best-effort and non-raising: returns the array of removed
129
- # paths. Continuation does not depend on these files (an intent resumes from its
130
- # savepoint.md ledger), so the only safety rule is age: a bridge older than
131
- # max_age_seconds is purged regardless of arm state, while anything newer is kept
132
- # (it may be a live run). The current session's own bridge is never purged
181
+ # --- Terminal-state bridge purge (intent 80) -------------------------------
182
+
183
+ # True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
184
+ # the PARENT of the store/ dir the bridge records, so we resolve it from the
185
+ # bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
186
+ # bad arg) returns false, which means "not active" so the caller treats the
187
+ # bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
188
+ # Array of id strings is supplied, membership is checked against it directly
189
+ # with no file read.
190
+ def self.intent_active?(intent_id, store:, index_active_ids: nil)
191
+ target = intent_id.to_s
192
+ return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
193
+
194
+ index = File.join(File.dirname(store.to_s), "INDEX.md")
195
+ return false unless File.exist?(index)
196
+
197
+ in_active = false
198
+ File.foreach(index) do |line|
199
+ stripped = line.chomp
200
+ if stripped == "## Active"
201
+ in_active = true
202
+ next
203
+ end
204
+ next unless in_active
205
+ break if stripped.start_with?("## ") # next section ends the Active block
206
+ m = stripped.match(/^- \[(\S+) +—/)
207
+ return true if m && m[1] == target
208
+ end
209
+ false
210
+ rescue StandardError
211
+ false
212
+ end
213
+
214
+ # Remove tmp/plastic-*.json bridge files whose intent is terminal, so
215
+ # discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
216
+ # returns the array of removed paths. A bridge is purged when it cannot be
217
+ # parsed, has no intent.id, has no intent.store, or its intent is not Active in
218
+ # its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
219
+ # anti-collision lock), and the current session's own bridge is never purged
133
220
  # (preserves the disarm_auto contract that it stays readable). Wired into
134
221
  # arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
135
- # clean.
136
- def self.purge_stale_bridges(session:, now: Time.now, max_age_seconds: PURGE_AGE_SECONDS,
137
- tmp: tmp_dir)
222
+ # clean at deterministic work boundaries.
223
+ def self.purge_done_bridges(session:, tmp: tmp_dir)
138
224
  current = path(session, tmp: tmp)
139
225
  removed = []
140
226
  Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
141
227
  next if f == current
142
228
  begin
143
- next if (now - File.mtime(f)) < max_age_seconds
229
+ data = JSON.parse(File.read(f)) rescue nil
230
+ keep = false
231
+ if data
232
+ id = data.dig("intent", "id")
233
+ store = data.dig("intent", "store")
234
+ keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
235
+ # Never purge a bridge whose intent still holds a delivery lock
236
+ # (intent 108, D6): the End tail clears the lock BEFORE the bridge
237
+ # becomes purge-eligible, so a held lock means the tail is not done.
238
+ unless keep
239
+ dir = bridge_intent_dir(data)
240
+ keep = !dir.nil? && File.exist?(Lock.path(dir))
241
+ end
242
+ end
243
+ next if keep
144
244
  File.delete(f)
145
245
  removed << f
146
246
  rescue Errno::ENOENT
@@ -152,7 +252,7 @@ module Bridge
152
252
  end
153
253
  removed
154
254
  rescue => e
155
- $stderr.puts "plastic: purge_stale_bridges failed: #{e.message}"
255
+ $stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
156
256
  removed || []
157
257
  end
158
258
 
@@ -224,6 +324,51 @@ module Bridge
224
324
  end
225
325
  end
226
326
 
327
+ # --- Gate-boundary narration (intent 84, Lever 1) -------------------------
328
+ #
329
+ # ONE concise sentence that states what happened AND what's next, preserving
330
+ # the `Next: ...` hint the agent consumes. Pure and side-effect-free so the
331
+ # hook stays a thin caller and the formatter is unit-testable in isolation.
332
+ # No "Stage transition: X -> Y" prose, no arrow; a colon/parentheses carry the
333
+ # stage word. Returns a single line (no embedded newlines).
334
+ STAGE_LABELS = {
335
+ "what" => "What", "why" => "Why", "how" => "How",
336
+ "exec" => "Exec", "done" => "Done"
337
+ }.freeze
338
+
339
+ NEXT_HINTS = {
340
+ "why" => "write spec.md",
341
+ "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
342
+ "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
343
+ "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
344
+ }.freeze
345
+
346
+ def self.stage_label(stage)
347
+ STAGE_LABELS[stage] || stage.to_s
348
+ end
349
+
350
+ # Build the gate-hook `additionalContext` sentence.
351
+ # transition: "PLASTIC: How reached (plan.md written). Next: <hint>"
352
+ # same-stage write: "PLASTIC: plan.md written (How). Next: <hint>"
353
+ # `new_missing` (missing files for the new stage) takes precedence over the
354
+ # stage hint, exactly as before, so the `Next:` content is unchanged.
355
+ def self.gate_narration(old_stage:, new_stage:, basename:, new_missing:, next_hints: NEXT_HINTS)
356
+ head = if old_stage != new_stage
357
+ "PLASTIC: #{stage_label(new_stage)} reached (#{basename} written)."
358
+ else
359
+ "PLASTIC: #{basename} written (#{stage_label(new_stage)})."
360
+ end
361
+
362
+ nxt =
363
+ if Array(new_missing).any?
364
+ "Next: #{Array(new_missing).join(", ")}"
365
+ elsif next_hints[new_stage]
366
+ "Next: #{next_hints[new_stage]}"
367
+ end
368
+
369
+ nxt ? "#{head} #{nxt}" : head
370
+ end
371
+
227
372
  # --- Cycle-step savepoint ledger (intent 34) ------------------------------
228
373
  #
229
374
  # savepoint.md is a deterministic, append-only, one-line-per-milestone ledger
@@ -256,8 +401,31 @@ module Bridge
256
401
  end.compact
257
402
  end
258
403
 
259
- # Append a milestone line for file_path if (and only if) it is a milestone
260
- # not already recorded. Returns true when a line was written, false otherwise.
404
+ # (stage, milestone) pairs already recorded in the ledger. The pair (not the
405
+ # milestone text alone) is the dedup key, because state-from-ledger lines like
406
+ # `Why started` and `How started` share the milestone text "started" while
407
+ # being distinct events (intent 81).
408
+ def self.savepoint_recorded_pairs(intent_dir)
409
+ f = File.join(intent_dir, SAVEPOINT_FILE)
410
+ return [] unless File.exist?(f)
411
+ File.read(f).each_line.filter_map do |line|
412
+ parts = line.strip.split(/\s{2,}/)
413
+ parts.length >= 3 ? [parts[1], parts[2]] : nil
414
+ end
415
+ end
416
+
417
+ # Append one ledger line for (stage, milestone) unless that pair is already
418
+ # recorded. The single append primitive shared by every line class. Returns
419
+ # true when a line was written, false when it was a no-op.
420
+ def self.append_savepoint_line(intent_dir, stage, milestone, now)
421
+ return false if savepoint_recorded_pairs(intent_dir).include?([stage, milestone])
422
+ line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
423
+ File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
424
+ true
425
+ end
426
+
427
+ # Append the artifact-landing milestone for file_path if (and only if) it is a
428
+ # milestone not already recorded. Returns true when a line was written.
261
429
  def self.append_savepoint(intent_dir, file_path, now: Time.now)
262
430
  basename = File.basename(file_path)
263
431
  stage, milestone = savepoint_milestone(intent_dir, basename)
@@ -265,11 +433,62 @@ module Bridge
265
433
  # A sentinel-marked lifecycle file logs NO milestone (the stage is not real
266
434
  # yet). The intent file is never sentineled, so it still logs its What line.
267
435
  return false unless stage_file_present?(File.join(intent_dir, basename))
268
- return false if savepoint_recorded_milestones(intent_dir).include?(milestone)
269
436
 
270
- line = "#{now.utc.iso8601} #{stage} #{milestone}\n"
271
- File.open(File.join(intent_dir, SAVEPOINT_FILE), "a") { |io| io.write(line) }
272
- true
437
+ append_savepoint_line(intent_dir, stage, milestone, now)
438
+ end
439
+
440
+ # --- State-from-ledger: pre-stage, exec-start, and terminal lines (81) ------
441
+ #
442
+ # On top of intent 34's artifact-landing milestones, the ledger gains:
443
+ # - `started` lines, one per cycle stage entry (pre-stage, written by the
444
+ # PreToolUse savepoint hook the moment a stage's artifact is first written);
445
+ # - an `Exec started` companion emitted when checklist.md lands;
446
+ # - a terminal `Done delivered|abandoned` line written by the completion path.
447
+ # None of these are derivable from files on disk, so they are deliberately NOT
448
+ # part of savepoint_milestone and are never regenerated by rebuild_savepoint:
449
+ # a rebuilt ledger is the file-landing skeleton, the live ledger is richer.
450
+
451
+ # Map a written filename to the [stage, "started"] pre-stage milestone, or nil.
452
+ # spec.md => entering Why, plan.md => entering How. checklist.md/outcome.md do
453
+ # not open a stage (checklist's Exec-start is the append_exec_started companion).
454
+ def self.savepoint_started_milestone(basename)
455
+ case basename
456
+ when "spec.md" then ["Why", "started"]
457
+ when "plan.md" then ["How", "started"]
458
+ end
459
+ end
460
+
461
+ # Append the pre-stage `started` line for file_path, iff: the basename opens a
462
+ # stage, the stage is genuinely starting (its artifact is not yet a REAL file,
463
+ # so a sentinel placeholder still counts as "starting"), and the pair is not
464
+ # already recorded. Returns true when a line was written.
465
+ def self.append_started_savepoint(intent_dir, file_path, now: Time.now)
466
+ basename = File.basename(file_path)
467
+ stage, milestone = savepoint_started_milestone(basename)
468
+ return false unless milestone
469
+ return false if stage_file_present?(File.join(intent_dir, basename))
470
+
471
+ append_savepoint_line(intent_dir, stage, milestone, now)
472
+ end
473
+
474
+ # Append the `Exec started` companion (emitted when checklist.md lands, in the
475
+ # same PostToolUse event as the `How checklist.md created` line). Idempotent.
476
+ def self.append_exec_started(intent_dir, now: Time.now)
477
+ append_savepoint_line(intent_dir, "Exec", "started", now)
478
+ end
479
+
480
+ TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
481
+
482
+ # Append the terminal bookend `Done delivered|abandoned`, written by the
483
+ # completion path when an intent transfers to INDEX's Completed/Abandoned
484
+ # section. Idempotent per disposition. Raises on an unknown disposition.
485
+ def self.append_terminal_savepoint(intent_dir, disposition, now: Time.now)
486
+ unless TERMINAL_DISPOSITIONS.include?(disposition)
487
+ raise ArgumentError,
488
+ "disposition must be one of #{TERMINAL_DISPOSITIONS.join(', ')}, got #{disposition.inspect}"
489
+ end
490
+
491
+ append_savepoint_line(intent_dir, "Done", disposition, now)
273
492
  end
274
493
 
275
494
  # Reconstruct the ledger from files on disk (timestamps from mtimes), in
@@ -290,7 +509,7 @@ module Bridge
290
509
  lines.length
291
510
  end
292
511
 
293
- def self.derive(session, intent_id:, intent_dir:, store:, name:)
512
+ def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
294
513
  stage = derive_stage(intent_dir)
295
514
  has = has_files(intent_dir)
296
515
  missing = missing_for_stage(stage, intent_dir) - has
@@ -320,10 +539,28 @@ module Bridge
320
539
  "context_pct" => 0,
321
540
  "warning_at" => 80,
322
541
  "critical_at" => 90
542
+ },
543
+ # Worktree isolation block (intent 73c). Born unprovisioned; arm_auto calls
544
+ # Worktree.provision to fill it. code/store are abs paths or null.
545
+ "worktree" => {
546
+ "code" => nil,
547
+ "code_branch" => nil,
548
+ "store" => nil,
549
+ "store_branch" => nil,
550
+ "provisioned" => false
551
+ },
552
+ # Delivery-lock CACHE block (intent 108, D2). The durable truth is the
553
+ # delivery.lock file in the intent dir; arm fills this cache from it.
554
+ "lock" => {
555
+ "owner_session" => nil,
556
+ "acquired_at" => nil,
557
+ "host" => nil,
558
+ "type" => nil,
559
+ "delegates" => []
323
560
  }
324
561
  }
325
562
 
326
- write(session, data)
563
+ write(session, data, tmp: tmp)
327
564
  data
328
565
  end
329
566
 
@@ -382,31 +619,157 @@ module Bridge
382
619
 
383
620
  # --- Auto mode (intent 27) ---
384
621
 
385
- # Arm auto mode for a session+intent. Works even when no bridge exists yet
386
- # (mid-session intent creation). Re-derives intent state, then sets build.auto.
387
- def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
622
+ # Shared arming spine (intent 96): resolve the session key, derive intent state,
623
+ # set the caller-controlled auto flag, acquire the delivery lock, provision the
624
+ # per-intent worktrees, persist, and purge terminal bridges. arm_auto (auto: true)
625
+ # and arm_guided (auto: false) are thin delegators so the lock-stamp + provision
626
+ # behaviour stays identical across both modes. Works even when no bridge exists
627
+ # yet (mid-session intent creation).
628
+ def self.arm(session, intent_id:, intent_dir:, store:, name:, auto:)
388
629
  key = resolve_session(session, intent_id: intent_id, store: store)
389
- if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"])
390
- $stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
630
+ if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
631
+ $stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
391
632
  end
392
633
  data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
393
- data["build"]["auto"] = true
634
+ data["build"]["auto"] = auto
635
+
636
+ # Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
637
+ # intent dir. The bridge lock block is a cache of the file.
638
+ intent_dir_abs = File.expand_path(intent_dir)
639
+ status, lock_data = Lock.acquire(intent_dir_abs, session: key)
640
+ case status
641
+ when :acquired, :owned
642
+ data["lock"] = lock_cache(lock_data)
643
+ when :held
644
+ raise LockHeldError, "delivery lock for intent #{intent_id} is held by " \
645
+ "session #{lock_data && lock_data['owner_session']}; run /plastic-lock status"
646
+ when :stale
647
+ raise LockHeldError, "delivery lock for intent #{intent_id} is stale " \
648
+ "(owner #{lock_data && lock_data['owner_session']}); run /plastic-lock " \
649
+ "reclaim to take it over with an audit"
650
+ when :excluded
651
+ raise LockHeldError, "a #{lock_data && lock_data['type']} lock is active on " \
652
+ "intent #{intent_id}; run /plastic-lock status"
653
+ when :corrupt
654
+ raise LockHeldError, "delivery.lock for intent #{intent_id} is unreadable; " \
655
+ "run /plastic-lock fix"
656
+ end
657
+
658
+ # Provision the per-intent worktrees (mandatory code worktree for project
659
+ # intents; fail-open for non-git / global-only). Never let a provision error
660
+ # break arming: the lock and auto flag still matter.
661
+ begin
662
+ Worktree.provision(data)
663
+ rescue => e
664
+ $stderr.puts "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
665
+ end
666
+
394
667
  write(key, data)
395
- purge_stale_bridges(session: key)
668
+ purge_done_bridges(session: key)
396
669
  data
397
670
  end
671
+ private_class_method :arm
398
672
 
399
- # Disarm auto mode. No-op if no bridge exists for the session.
673
+ # Arm auto mode for a session+intent. Works even when no bridge exists yet
674
+ # (mid-session intent creation). Re-derives intent state, then sets build.auto.
675
+ def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
676
+ arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: true)
677
+ end
678
+
679
+ # Acquire the delivery lock WITHOUT auto mode (intent 96 / Start guided branch).
680
+ # Mirrors arm_auto's lock-stamp + worktree provision but leaves build.auto = false.
681
+ # Same signature as arm_auto; disarm_auto (mode-agnostic) releases a guided lock.
682
+ def self.arm_guided(session, intent_id:, intent_dir:, store:, name:)
683
+ arm(session, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name, auto: false)
684
+ end
685
+
686
+ # Disarm. No-op if no bridge exists for the session. End-tail order (D6):
687
+ # worktrees are merged/removed FIRST (the verify step is the caller's,
688
+ # before disarm), then the delivery lock is cleared, and only then does the
689
+ # bridge become purge-eligible. purge_done_bridges enforces the same order
690
+ # defensively by skipping any bridge whose intent still holds a lock.
400
691
  def self.disarm_auto(session)
401
692
  data = read(session)
402
693
  return nil unless data
403
694
  data["build"] ||= {}
404
695
  data["build"]["auto"] = false
696
+
697
+ # Release the worktrees the matching arm provisioned (intent 73c). Non-fatal:
698
+ # a release error must not block disarming. CLEANUP (73c3) refines the
699
+ # merge-vs-remove policy on the completion/release path.
700
+ begin
701
+ Worktree.release(data)
702
+ rescue => e
703
+ $stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
704
+ end
705
+
706
+ dir = bridge_intent_dir(data)
707
+ if dir
708
+ owner = data.dig("lock", "owner_session")
709
+ owner = session if blank?(owner)
710
+ Lock.release(dir, session: owner)
711
+ end
712
+ data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
713
+ "host" => nil, "type" => nil, "delegates" => [] }
714
+
405
715
  write(session, data)
406
- purge_stale_bridges(session: session)
716
+ purge_done_bridges(session: session)
407
717
  data
408
718
  end
409
719
 
720
+ # One deterministic, idempotent repair (intent 108, D5): diagnose, remove
721
+ # faulty own-side state, rebuild the durable lock AND the bridge cache from
722
+ # disk truth for the current session. Legacy /tmp-only pid locks are
723
+ # migrated here: the delivery.lock file is created and the cache rebuilt
724
+ # without a pid. NEVER touches a fresh foreign lock (reports "held"); a
725
+ # stale foreign lock reports "stale" and is taken only by the explicit
726
+ # reclaim verb (Lock.takeover). Two entry points call this: the
727
+ # plastic-lock CLI and /plastic-intent-starting (self-healing boarding).
728
+ def self.repair_lock(session, intent_id:, intent_dir:, store:, name:,
729
+ now: Time.now, tmp: tmp_dir)
730
+ key = resolve_session(session, intent_id: intent_id, store: store)
731
+ dir = File.expand_path(intent_dir)
732
+ actions = []
733
+
734
+ if Lock.corrupt?(dir)
735
+ File.delete(Lock.path(dir))
736
+ actions << "removed corrupt delivery.lock"
737
+ end
738
+
739
+ lock = Lock.read(dir)
740
+ if lock && !Lock.authorized?(lock, key)
741
+ if Lock.fresh?(dir, now: now)
742
+ return { "status" => "held", "owner" => lock["owner_session"],
743
+ "actions" => actions, "session" => key }
744
+ end
745
+ return { "status" => "stale", "owner" => lock["owner_session"],
746
+ "actions" => actions, "session" => key,
747
+ "hint" => "run /plastic-lock reclaim to take over with an audit" }
748
+ end
749
+
750
+ if lock
751
+ Lock.heartbeat(dir, session: key, now: now)
752
+ lock_data = Lock.read(dir)
753
+ role = lock_data["owner_session"].to_s == key ? "owner" : "delegate"
754
+ actions << "lock kept (#{role})"
755
+ else
756
+ status, lock_data = Lock.acquire(dir, session: key, now: now)
757
+ actions << "lock #{status}"
758
+ end
759
+
760
+ previous = read(key, tmp: tmp)
761
+ auto = !!(previous && previous.dig("build", "auto"))
762
+ data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
763
+ name: name, tmp: tmp)
764
+ data["build"]["auto"] = auto
765
+ data["lock"] = lock_cache(lock_data)
766
+ write(key, data, tmp: tmp)
767
+ actions << "bridge rebuilt from disk (stage #{data['build']['stage']})"
768
+
769
+ { "status" => "repaired", "actions" => actions,
770
+ "lock" => lock_data, "session" => key }
771
+ end
772
+
410
773
  # Decide whether a code edit should be blocked while auto mode is armed.
411
774
  # Returns a reason string to BLOCK, or nil to ALLOW.
412
775
  #
@@ -441,6 +804,182 @@ module Bridge
441
804
  "(blocked edit: #{file_abs})"
442
805
  end
443
806
 
807
+ # --- Fail-closed lock gate (intent 96) -------------------------------------
808
+
809
+ # Returns a reason String to BLOCK, or nil to ALLOW. Decides from the
810
+ # durable delivery.lock in the TARGET intent dir (D2): the bridge argument
811
+ # only supplies a fallback session id, so a missing or disagreeing bridge
812
+ # never changes the verdict. Every deny names the exact resolving command
813
+ # (D5). ALLOW: non-intent paths, not-yet-active intents, and any session the
814
+ # target's lock names as owner or delegate (even when stale: a stale lock is
815
+ # still its owner's until an explicit takeover).
816
+ def self.lock_gate_decision(bridge_data, file_path, session: nil,
817
+ ttl: Lock::TTL_SECONDS, now: Time.now)
818
+ return nil if blank?(file_path)
819
+
820
+ target_dir = intent_dir_for(file_path)
821
+ return nil unless target_dir
822
+ id = intent_id_from_dir(target_dir)
823
+ store = File.dirname(target_dir)
824
+ return nil unless id && intent_active?(id, store: store)
825
+
826
+ sess = session
827
+ sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
828
+
829
+ lock = Lock.read(target_dir)
830
+ if lock
831
+ return nil if Lock.authorized?(lock, sess)
832
+ if Lock.fresh?(target_dir, ttl: ttl, now: now)
833
+ return "intent #{id} delivery lock is held by session " \
834
+ "#{lock['owner_session']}. Back off; if you are the owner's " \
835
+ "subagent, the owner must run: plastic-lock delegate " \
836
+ "--intent-dir #{target_dir} --session <your-session-id>. " \
837
+ "Inspect with /plastic-lock status"
838
+ end
839
+ return "intent #{id} has a stale delivery lock (owner " \
840
+ "#{lock['owner_session']}); run /plastic-lock reclaim to take " \
841
+ "it over, or /plastic-lock fix"
842
+ end
843
+ if Lock.corrupt?(target_dir)
844
+ return "delivery.lock for intent #{id} is unreadable; run /plastic-lock fix"
845
+ end
846
+ "no delivery lock held for intent #{id}; run /plastic-intent-starting " \
847
+ "to lock and begin"
848
+ end
849
+
850
+ # A session holds an intent's lock iff the durable delivery.lock in the
851
+ # intent dir names it as owner or delegate (D1/D4). The bridge is only a
852
+ # cache: the lock FILE decides, so a wiped /tmp or a clobbered bridge never
853
+ # strands the owner. No pid is consulted anywhere.
854
+ def self.holds_live_lock?(bridge_data, session: nil)
855
+ sess = session
856
+ sess = bridge_data["session"] if blank?(sess) && bridge_data.is_a?(Hash)
857
+ return false if blank?(sess)
858
+ dir = bridge_intent_dir(bridge_data)
859
+ return false unless dir
860
+ Lock.holds?(dir, session: sess)
861
+ end
862
+
863
+ # "<id>" from a ".../store/<id>--<slug>" dir, else nil.
864
+ def self.intent_id_from_dir(dir)
865
+ base = File.basename(dir.to_s)
866
+ base.include?("--") ? base.split("--", 2).first : nil
867
+ end
868
+
869
+ # --- Worktree isolation gate (intent 73c2) ---
870
+
871
+ # Returns a reason String to BLOCK, or nil to ALLOW. Two independent rules,
872
+ # both fail-open by construction:
873
+ #
874
+ # 1. When the bridge has a provisioned code worktree, a code edit (a target
875
+ # outside ~/.plastic and outside this intent's store dir) MUST land inside
876
+ # worktree["code"]; otherwise BLOCK and name the expected worktree path.
877
+ # 2. When the target lives inside ANOTHER intent's store dir whose bridge lock
878
+ # is held by a LIVE non-owner session, BLOCK (non-owner edit to an active
879
+ # intent).
880
+ #
881
+ # Fails open (returns nil) when provisioned is false (non-git / global-only) or
882
+ # the bridge carries no worktree/lock blocks. Logs nothing on the allow path.
883
+ def self.worktree_gate_decision(bridge_data, file_path, home: Dir.home, current_session: nil)
884
+ return nil unless bridge_data.is_a?(Hash)
885
+ return nil if blank?(file_path)
886
+
887
+ file_abs = File.expand_path(file_path.to_s)
888
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
889
+ under_plastic = file_abs == plastic_home || file_abs.start_with?("#{plastic_home}/")
890
+
891
+ intent_info = bridge_data["intent"] || {}
892
+ store = intent_info["store"]
893
+ dir = intent_info["dir"]
894
+ intent_dir_abs = (store && dir) ? File.expand_path("#{store}/#{dir}") : nil
895
+ under_own_intent = intent_dir_abs &&
896
+ (file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/"))
897
+
898
+ # Rule 1 (fixed in intent 108, D7): confinement applies ONLY to paths
899
+ # inside the project repo. The repo root is derived from the provisioned
900
+ # code worktree path, which is <repo>/.claude/worktrees/{id}--{slug} by
901
+ # construction, so no git call is needed. Paths outside the repo (agent
902
+ # memory dirs, scratch files, unrelated checkouts) are not this gate's
903
+ # business; the 2026-07-02 memory-dir denial came from treating everything
904
+ # outside the worktree as the shared checkout.
905
+ worktree = bridge_data["worktree"] || {}
906
+ if worktree["provisioned"] == true
907
+ code = worktree["code"].to_s
908
+ if !blank?(code) && !under_plastic && !under_own_intent
909
+ code_abs = File.expand_path(code)
910
+ repo_abs = File.expand_path(File.join(code_abs, "..", "..", ".."))
911
+ inside_repo = file_abs == repo_abs || file_abs.start_with?("#{repo_abs}/")
912
+ inside_code = file_abs == code_abs || file_abs.start_with?("#{code_abs}/")
913
+ if inside_repo && !inside_code
914
+ id = intent_info["id"]
915
+ return "intent #{id} is isolated to its worktree - edit project code " \
916
+ "inside #{code_abs}, not the shared checkout. (blocked edit: #{file_abs})"
917
+ end
918
+ end
919
+ end
920
+
921
+ # Rule 2: do not edit another intent's locked, live store dir.
922
+ if under_plastic
923
+ reason = non_owner_store_edit_reason(file_abs, plastic_home, intent_dir_abs,
924
+ home: home, current_session: current_session,
925
+ own_session: bridge_data["session"])
926
+ return reason if reason
927
+ end
928
+
929
+ nil
930
+ end
931
+
932
+ # Helper for rule 2. A store dir is `<plastic_home>/store/{id}--{slug}` (global)
933
+ # or `<plastic_home>/projects/{slug}/store/{id}--{slug}` (project). When the
934
+ # edit target sits inside such a dir that is NOT this intent's own dir, and a
935
+ # live non-owner session holds that intent's bridge lock, BLOCK.
936
+ def self.non_owner_store_edit_reason(file_abs, plastic_home, own_intent_dir_abs,
937
+ home:, current_session:, own_session:)
938
+ return nil if own_intent_dir_abs &&
939
+ (file_abs == own_intent_dir_abs || file_abs.start_with?("#{own_intent_dir_abs}/"))
940
+
941
+ parsed = parse_store_target(file_abs, plastic_home)
942
+ return nil unless parsed
943
+
944
+ session = blank?(current_session) ? own_session : current_session
945
+ held = Worktree.lock_held_by_other?(
946
+ intent_id: parsed[:id], store: parsed[:store],
947
+ current_session: session, home: home,
948
+ )
949
+ return nil unless held
950
+
951
+ "intent #{parsed[:id]} is owned by another live session — its delivery lock " \
952
+ "is held elsewhere. Back off; do not edit #{file_abs}."
953
+ end
954
+
955
+ # Resolve an edit target inside a store to {id:, store:} for the intent dir it
956
+ # belongs to, or nil if the path is not inside an `{id}--{slug}` intent dir.
957
+ def self.parse_store_target(file_abs, plastic_home)
958
+ rels = []
959
+ global_store = File.join(plastic_home, "store")
960
+ if file_abs.start_with?("#{global_store}/")
961
+ rels << [file_abs[(global_store.length + 1)..], global_store]
962
+ end
963
+ projects = File.join(plastic_home, "projects")
964
+ if file_abs.start_with?("#{projects}/")
965
+ tail = file_abs[(projects.length + 1)..].to_s
966
+ parts = tail.split(File::SEPARATOR)
967
+ if parts.length >= 2 && parts[1] == "store"
968
+ pstore = File.join(projects, parts[0], "store")
969
+ rels << [file_abs[(pstore.length + 1)..], pstore]
970
+ end
971
+ end
972
+
973
+ rels.each do |rel, store_dir|
974
+ next if blank?(rel)
975
+ first = rel.split(File::SEPARATOR).first.to_s
976
+ idx = first.index("--")
977
+ next unless idx && idx > 0
978
+ return { id: first[0...idx], store: store_dir }
979
+ end
980
+ nil
981
+ end
982
+
444
983
  # --- Bash-edit gate (intent 27a) ---
445
984
 
446
985
  # Extract the set of file paths a Bash command writes to. Conservative by
@@ -457,6 +996,7 @@ module Bridge
457
996
  # Split on command separators for per-segment utility parsing.
458
997
  command.split(/[;\n]|&&|\|\||\|/).each do |segment|
459
998
  targets.concat(bash_utility_targets(segment))
999
+ targets.concat(interpreter_write_targets(segment))
460
1000
  end
461
1001
  targets.uniq
462
1002
  end
@@ -532,18 +1072,56 @@ module Bridge
532
1072
  path == "/dev/null" || path.start_with?("/dev/")
533
1073
  end
534
1074
 
535
- # Decide whether a Bash command should be blocked under the auto-mode code
536
- # gate. Resolves each write target against cwd and applies the SAME policy as
537
- # code_gate_decision. Returns the first block reason, or nil to allow.
538
- def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home)
1075
+ # --- Interpreter inline-code writes (intent 108, D7) ---
1076
+
1077
+ INTERPRETER_RE = /\b(ruby|python3?|perl|node)\b(?:\s+\S+)*?\s+(-e|-c)\s+(.+)\z/m.freeze
1078
+
1079
+ # Write verbs that mark inline code as file-mutating. Conservative: reads
1080
+ # (File.read, puts) never match.
1081
+ WRITE_VERB_RE = /File\.(?:write|binwrite|open)|IO\.write|FileUtils\.|
1082
+ open\s*\([^)]*["'][wa]|writeFileSync|fs\.write/x.freeze
1083
+
1084
+ # Quoted absolute or ~/ paths inside the inline code.
1085
+ INLINE_PATH_RE = %r{["']((?:/|~/)[^"']+)["']}.freeze
1086
+
1087
+ # Paths an interpreter one-liner writes. Flagged only when the inline code
1088
+ # has BOTH a write verb AND a quoted absolute path; everything else (reads,
1089
+ # ARGV-driven paths, the sanctioned arm one-liners) yields no targets.
1090
+ def self.interpreter_write_targets(segment)
1091
+ m = INTERPRETER_RE.match(segment.to_s)
1092
+ return [] unless m
1093
+ util, flag, code = m[1], m[2], m[3]
1094
+ expected = { "ruby" => "-e", "python" => "-c", "python3" => "-c",
1095
+ "perl" => "-e", "node" => "-e" }[util]
1096
+ return [] unless flag == expected
1097
+ return [] unless WRITE_VERB_RE.match?(code)
1098
+ code.scan(INLINE_PATH_RE).flatten.map { |p| File.expand_path(p) }
1099
+ end
1100
+
1101
+ # Decide whether a Bash command should be blocked. Every write target runs
1102
+ # through the SAME policy stack as a direct tool write: the auto-mode code
1103
+ # gate AND the delivery-lock gate (intent 108, D7), so bash and interpreter
1104
+ # writes cannot bypass the lock. Returns the first block reason, or nil.
1105
+ def self.bash_gate_decision(bridge_data, command, cwd:, home: Dir.home, session: nil)
539
1106
  bash_write_targets(command).each do |target|
540
1107
  abs = File.absolute_path?(target) ? target : File.join(cwd, target)
541
- reason = code_gate_decision(bridge_data, abs, home: home)
1108
+ abs = File.expand_path(abs)
1109
+ reason = code_gate_decision(bridge_data, abs, home: home) ||
1110
+ lock_gate_decision(bridge_data, abs, session: session)
542
1111
  return reason if reason
543
1112
  end
544
1113
  nil
545
1114
  end
546
1115
 
1116
+ # A TRAILING `# plastic-ok` shell comment: the auditable escape for
1117
+ # sanctioned bash/interpreter writes (mirrors the retrieval gate's
1118
+ # `# qmd-ok`). The hook logs every use to ~/.plastic/.cache/gate-escapes.log.
1119
+ PLASTIC_OK_RE = /(?:\A|\s)#\s*plastic-ok\s*\z/.freeze
1120
+
1121
+ def self.bash_escape?(command)
1122
+ PLASTIC_OK_RE.match?(command.to_s.chomp)
1123
+ end
1124
+
547
1125
  def self.deep_merge(base, overlay)
548
1126
  result = base.dup
549
1127
  overlay.each do |key, value|