@zalom/plastic 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/PLASTIC.md +23 -8
  2. package/README.md +15 -4
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-discovery.md +7 -0
  5. package/bin/plastic.js +17 -8
  6. package/hooks/auto-arm +2 -2
  7. package/hooks/bash-gate +1 -1
  8. package/hooks/check-update +1 -1
  9. package/hooks/continue +2 -2
  10. package/hooks/edit-gates +1 -1
  11. package/hooks/future-intent-check +2 -2
  12. package/hooks/gate-check +3 -3
  13. package/hooks/power-tools +1 -1
  14. package/hooks/session-start +1 -1
  15. package/package.json +1 -1
  16. package/scripts/codex-hook +50 -106
  17. package/scripts/doctor.rb +132 -1080
  18. package/scripts/exec-worktree +103 -0
  19. package/scripts/hash-intent +1 -1
  20. package/scripts/hook-bash-gate +19 -0
  21. package/scripts/hook-code-gate +4 -1
  22. package/scripts/hook-continue +2 -2
  23. package/scripts/hook-create-gate +6 -3
  24. package/scripts/hook-gate-check +17 -0
  25. package/scripts/hook-links-gate +4 -1
  26. package/scripts/hook-lock-gate +7 -3
  27. package/scripts/hook-savepoint-pre +4 -1
  28. package/scripts/hook-session-start +21 -15
  29. package/scripts/lib/apply_patch_envelope.rb +46 -13
  30. package/scripts/lib/bridge.rb +83 -15
  31. package/scripts/lib/codex_edit_gates.rb +138 -0
  32. package/scripts/lib/doctor_core.rb +1087 -0
  33. package/scripts/lib/edit_gates.rb +61 -5
  34. package/scripts/lib/exec_worktree.rb +325 -0
  35. package/scripts/lib/harness_text.rb +57 -0
  36. package/scripts/lib/hook_registry.rb +32 -28
  37. package/scripts/lib/installer_core.rb +67 -7
  38. package/scripts/lib/lock.rb +196 -47
  39. package/scripts/lib/ruby_probe.rb +60 -0
  40. package/scripts/lib/scaffold_intent.rb +392 -0
  41. package/scripts/lib/spec_header.rb +83 -0
  42. package/scripts/lib/start_intent.rb +296 -0
  43. package/scripts/lib/verify_intent.rb +262 -0
  44. package/scripts/lib/worktree.rb +15 -1
  45. package/scripts/link-suggest +1 -1
  46. package/scripts/maintenance-run +5 -5
  47. package/scripts/migrate-to-global +2 -2
  48. package/scripts/restore-intent-v1 +1 -1
  49. package/scripts/scaffold-intent +120 -0
  50. package/scripts/start-intent +89 -0
  51. package/scripts/verify-intent +73 -0
  52. package/skills/agent-advisor/SKILL.md +5 -5
  53. package/skills/auto/SKILL.md +42 -32
  54. package/skills/auto/references/agent-architecture.md +1 -1
  55. package/skills/auto/references/agent-report-contract.md +1 -1
  56. package/skills/auto/references/human-report-contract.md +22 -3
  57. package/skills/auto/references/tiers.md +24 -2
  58. package/skills/conventions/references/completion-and-done.md +3 -0
  59. package/skills/conventions/references/gates-and-enforcement.md +28 -12
  60. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  61. package/skills/conventions/references/tiers-and-dispatch.md +7 -6
  62. package/skills/dashboard/SKILL.md +1 -1
  63. package/skills/doctor/SKILL.md +6 -5
  64. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  65. package/skills/doctor/report.md +1 -1
  66. package/skills/install/SKILL.md +1 -1
  67. package/skills/intent-brainstorming/SKILL.md +0 -2
  68. package/skills/intent-creating/SKILL.md +6 -6
  69. package/skills/intent-creating/references/lifecycle.md +1 -1
  70. package/skills/intent-discovering/SKILL.md +10 -3
  71. package/skills/intent-ending/SKILL.md +8 -7
  72. package/skills/intent-executing/SKILL.md +27 -19
  73. package/skills/intent-grilling/SKILL.md +5 -3
  74. package/skills/intent-planning/SKILL.md +7 -3
  75. package/skills/intent-researching/SKILL.md +0 -2
  76. package/skills/intent-starting/SKILL.md +10 -2
  77. package/skills/project-creating/SKILL.md +0 -2
  78. package/skills/project-creating/references/project-scaffolding.md +1 -1
  79. package/skills/releasing/SKILL.md +1 -1
  80. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  81. package/skills/releasing/references/release-lines.md +1 -1
  82. package/skills/skill-creating/SKILL.md +5 -2
  83. package/skills/store-indexing/SKILL.md +8 -5
  84. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  85. package/skills/tutorial/references/track-1-guided.md +2 -2
  86. package/skills/tutorial/references/track-2-auto.md +9 -6
  87. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  88. package/skills/uninstall/SKILL.md +6 -9
  89. package/templates/agents.md +12 -12
  90. package/templates/config.yml +6 -7
  91. package/templates/index.md +6 -3
  92. package/templates/spec.md +1 -1
@@ -8,6 +8,7 @@ require "digest"
8
8
  require "time"
9
9
  require_relative "hook_registry"
10
10
  require_relative "agent_models"
11
+ require_relative "harness_text"
11
12
 
12
13
  # Shared installer machinery, instantiable with injected package root / store / agent
13
14
  # map so the verb scripts (install/update/uninstall/rollback) and their tests can run
@@ -245,6 +246,7 @@ class InstallerCore
245
246
  FileUtils.mkdir_p(plastic_home)
246
247
  FileUtils.mkdir_p(File.join(plastic_home, "scripts", "lib"))
247
248
  FileUtils.mkdir_p(File.join(plastic_home, "templates"))
249
+ FileUtils.mkdir_p(File.join(plastic_home, "hooks"))
248
250
 
249
251
  core_files.each do |src, dest|
250
252
  src_path = File.join(package_root, src)
@@ -258,6 +260,14 @@ class InstallerCore
258
260
  File.write(File.join(plastic_home, "VERSION"), "#{version}\n")
259
261
 
260
262
  Dir.glob(File.join(plastic_home, "scripts", "*")).each { |f| FileUtils.chmod(0o755, f) if File.file?(f) }
263
+ # Same treatment for the hook launchers, and UNCONDITIONAL on update as well as install:
264
+ # FileUtils.cp onto an existing file keeps the DESTINATION's old mode, so a copy over a
265
+ # non-executable predecessor would stay non-executable forever and capture3 would raise
266
+ # EACCES into the same silent fail-open this intent is closing. *.json is skipped because
267
+ # hooks.json is a registry, not a program (same reasoning as test/rubyopt_clearing_test.rb).
268
+ Dir.glob(File.join(plastic_home, "hooks", "*")).each do |f|
269
+ FileUtils.chmod(0o755, f) if File.file?(f) && !f.end_with?(".json")
270
+ end
261
271
 
262
272
  global_files = core_files.values.map { |d| File.join(plastic_home, d) }
263
273
  global_files << File.join(plastic_home, "VERSION")
@@ -283,12 +293,30 @@ class InstallerCore
283
293
  end
284
294
  end
285
295
 
296
+ # Hook launchers ship in full: scripts/codex-hook resolves a live-state launcher at
297
+ # __dir__/../hooks/<gate>, which is ~/.plastic/hooks/<gate> once installed, so the launchers
298
+ # have to BE there or every Codex live-state hook fails open with no message (intent 249).
299
+ # Glob-derived for the same reason template_files is: a hand-written list hid two template
300
+ # files from every install for five weeks (intent 190), and a new hook must register itself.
301
+ # No path rewrite on this copy, unlike install_claude's: the launchers resolve their core
302
+ # through "$SCRIPT_DIR/../scripts/", which from ~/.plastic/hooks/ already lands on
303
+ # ~/.plastic/scripts/. Copied whole, hooks.json and run-hook and statusline included: all
304
+ # three are inert at that path, and an exclusion list is exactly the maintenance this avoids.
305
+ def hook_files
306
+ Dir.glob(File.join(package_root, "hooks", "*")).each_with_object({}) do |path, acc|
307
+ next unless File.file?(path)
308
+
309
+ rel = File.join("hooks", File.basename(path))
310
+ acc[rel] = rel
311
+ end
312
+ end
313
+
286
314
  # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
287
315
  # must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
288
316
  # by install_sync_test). The templates half is glob-derived (template_files above); the
289
317
  # rest stays a hand-written literal.
290
318
  def core_files
291
- hand_registered_files.merge(template_files)
319
+ hand_registered_files.merge(template_files).merge(hook_files)
292
320
  end
293
321
 
294
322
  def hand_registered_files
@@ -308,6 +336,7 @@ class InstallerCore
308
336
  "scripts/hook-power-tools" => "scripts/hook-power-tools",
309
337
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
310
338
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
339
+ "scripts/lib/ruby_probe.rb" => "scripts/lib/ruby_probe.rb",
311
340
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
312
341
  "scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
313
342
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
@@ -357,6 +386,8 @@ class InstallerCore
357
386
  "scripts/lib/edit_gates.rb" => "scripts/lib/edit_gates.rb",
358
387
  "scripts/hook-edit-gates" => "scripts/hook-edit-gates",
359
388
  "scripts/lib/apply_patch_envelope.rb" => "scripts/lib/apply_patch_envelope.rb",
389
+ "scripts/lib/codex_edit_gates.rb" => "scripts/lib/codex_edit_gates.rb",
390
+ "scripts/lib/harness_text.rb" => "scripts/lib/harness_text.rb",
360
391
  "scripts/codex-hook" => "scripts/codex-hook",
361
392
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
362
393
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
@@ -370,7 +401,17 @@ class InstallerCore
370
401
  "scripts/uninstall.rb" => "scripts/uninstall.rb",
371
402
  "scripts/rollback.rb" => "scripts/rollback.rb",
372
403
  "scripts/lib/outcome_guard.rb" => "scripts/lib/outcome_guard.rb",
404
+ "scripts/lib/spec_header.rb" => "scripts/lib/spec_header.rb",
405
+ "scripts/lib/scaffold_intent.rb" => "scripts/lib/scaffold_intent.rb",
406
+ "scripts/scaffold-intent" => "scripts/scaffold-intent",
407
+ "scripts/lib/verify_intent.rb" => "scripts/lib/verify_intent.rb",
408
+ "scripts/verify-intent" => "scripts/verify-intent",
409
+ "scripts/lib/start_intent.rb" => "scripts/lib/start_intent.rb",
410
+ "scripts/start-intent" => "scripts/start-intent",
411
+ "scripts/lib/exec_worktree.rb" => "scripts/lib/exec_worktree.rb",
412
+ "scripts/exec-worktree" => "scripts/exec-worktree",
373
413
  "scripts/doctor.rb" => "scripts/doctor.rb",
414
+ "scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
374
415
  "scripts/dashboard.rb" => "scripts/dashboard.rb",
375
416
  "scripts/skill-lint" => "scripts/skill-lint",
376
417
  "scripts/lib/skill_lint.rb" => "scripts/lib/skill_lint.rb",
@@ -760,7 +801,16 @@ class InstallerCore
760
801
  installed = []
761
802
  skills_source = File.join(package_root, "skills")
762
803
  skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
763
- installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
804
+ # Intent 239: Codex is the one harness that gets its instruction text projected at
805
+ # copy time. skill_names comes from the real skills/ listing, so the rewrite table
806
+ # maintains itself as skills are added and renamed. Every OTHER install path passes
807
+ # no transform and keeps the byte-for-byte copy.
808
+ skill_names = Dir.children(skills_source).select { |e| File.directory?(File.join(skills_source, e)) }
809
+ codex_transform = lambda do |content, rel|
810
+ HarnessText.for_codex(content, rel_path: rel, skill_names: skill_names)
811
+ end
812
+ installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"),
813
+ exclude: skill_exclude, transform: codex_transform) if File.directory?(skills_source)
764
814
  # Codex-scoped overrides only (agents.models.codex.*): a literal Claude
765
815
  # model id set under agents.models.claude.* (or the legacy flat form,
766
816
  # which resolves as claude) must never reach a Codex TOML.
@@ -952,15 +1002,19 @@ class InstallerCore
952
1002
  # instead, so every skill can read it from one shared location. `exclude` skips
953
1003
  # named top-level skill directories entirely (intent 185: the agent-advisor skill
954
1004
  # when advisor.enabled is false).
955
- def install_skills_flat(skills_source, skills_root, exclude: [])
1005
+ def install_skills_flat(skills_source, skills_root, exclude: [], transform: nil)
956
1006
  installed = []
957
1007
  FileUtils.mkdir_p(skills_root)
958
1008
 
959
1009
  Dir.children(skills_source).reject { |e| e.start_with?(".") || exclude.include?(e) }.each do |entry|
960
1010
  src = File.join(skills_source, entry)
961
1011
  if File.directory?(src)
962
- installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"))
1012
+ installed += copy_dir_recursive(src, File.join(skills_root, "plastic-#{entry}"),
1013
+ transform: transform, rel_prefix: entry)
963
1014
  elsif entry.start_with?("_") && entry.end_with?(".md")
1015
+ # Spec D8: shared fragments land in the HARNESS-NEUTRAL plastic_home, shared
1016
+ # with any co-installed Claude. Never transformed, or a Codex install would
1017
+ # corrupt Claude's copy of the same file.
964
1018
  FileUtils.mkdir_p(plastic_home)
965
1019
  dest = File.join(plastic_home, entry)
966
1020
  FileUtils.cp(src, dest)
@@ -1459,16 +1513,22 @@ class InstallerCore
1459
1513
  agents.find { |a| a[:key] == key }
1460
1514
  end
1461
1515
 
1462
- def copy_dir_recursive(src, dest)
1516
+ def copy_dir_recursive(src, dest, transform: nil, rel_prefix: "")
1463
1517
  files = []
1464
1518
  FileUtils.mkdir_p(dest)
1465
1519
  Dir.entries(src).reject { |e| e.start_with?(".") }.each do |entry|
1466
1520
  src_path = File.join(src, entry)
1467
1521
  dest_path = File.join(dest, entry)
1522
+ rel = rel_prefix.empty? ? entry : File.join(rel_prefix, entry)
1468
1523
  if File.directory?(src_path)
1469
- files += copy_dir_recursive(src_path, dest_path)
1524
+ files += copy_dir_recursive(src_path, dest_path, transform: transform, rel_prefix: rel)
1470
1525
  elsif File.file?(src_path)
1471
- FileUtils.cp(src_path, dest_path)
1526
+ if transform && File.extname(entry) == ".md"
1527
+ File.write(dest_path, transform.call(File.read(src_path), rel))
1528
+ File.chmod(File.stat(src_path).mode & 0o7777, dest_path)
1529
+ else
1530
+ FileUtils.cp(src_path, dest_path)
1531
+ end
1472
1532
  files << dest_path
1473
1533
  end
1474
1534
  end
@@ -56,6 +56,37 @@ module Lock
56
56
  # either way (takeover), so the TTL only bounds WHEN takeover is allowed.
57
57
  TTL_SECONDS = 1800
58
58
 
59
+ # The write guard is a mutex, not a lock in the Plastic sense: it carries no
60
+ # owner, no timestamp, and no content, it is only an inode to flock. It is
61
+ # a SIBLING of delivery.lock (never delivery.lock itself), because renaming
62
+ # over a file you hold an flock on leaves you holding an orphaned inode
63
+ # while the next writer flocks a fresh one at the same path. The name lands
64
+ # inside the existing *.lock rule in ~/.plastic/.gitignore, so no gitignore
65
+ # change is needed.
66
+ WRITE_GUARD_TIMEOUT_SECONDS = 2.0
67
+ WRITE_GUARD_RETRY_SECONDS = 0.01
68
+
69
+ # The sibling guard path for a lock type. NOT built by passing a compound
70
+ # type: into path (see TYPES comment above): Lock.path(dir, type:
71
+ # "delivery.write") would render the same string, and the exclusion at
72
+ # acquire ((TYPES - [type]).first) silently checks only one other type, so
73
+ # a third TYPES entry would break mutual exclusion with no error. This
74
+ # helper keeps the guard entirely outside TYPES.
75
+ def write_guard_path(intent_dir, type: "delivery")
76
+ File.join(intent_dir, "#{type}.write.lock")
77
+ end
78
+
79
+ # The sibling temp path for write's write-to-temp-then-rename. Ends in
80
+ # ".lock" (not just ".tmp") so a temp orphaned by a crash between the
81
+ # write and the rename is covered by the store's existing *.lock
82
+ # .gitignore rule rather than committed by the store's `git add -A`
83
+ # auto-commit. Sibling in the same directory as the target, never a
84
+ # system tmpdir, because File.rename can raise EXDEV across filesystems.
85
+ def write_temp_path(intent_dir, type: "delivery")
86
+ "#{path(intent_dir, type: type)}.tmp.#{Process.pid}.#{Thread.current.object_id}." \
87
+ "#{Time.now.to_f}.lock"
88
+ end
89
+
59
90
  def blank?(value)
60
91
  value.nil? || value.to_s.strip.empty?
61
92
  end
@@ -122,15 +153,30 @@ module Lock
122
153
  existing = read(intent_dir, type: type)
123
154
  if existing
124
155
  if existing["owner_session"].to_s == session.to_s
125
- data = payload(session: session, type: type, host: host, now: now,
126
- delegates: Array(existing["delegates"]),
127
- delegate_activity: Array(existing["delegate_activity"]),
128
- harness: merged_value(harness, existing["owner_harness"]),
129
- agent: merged_value(agent, existing["owner_agent"]),
130
- model: merged_value(model, existing["owner_model"]),
131
- thread: merged_value(thread, existing["owner_thread"]),
132
- run_mode: merged_value(run_mode, existing["run_mode"]))
133
- write(intent_dir, data, type: type)
156
+ # Re-read happens INSIDE the guard so the read-modify-write is
157
+ # covered, not just the write (spec D5). Fall back to the
158
+ # already-read existing when the file has vanished underneath us.
159
+ # If the re-read shows a different owner, keep today's outcome and
160
+ # write anyway: an owner-changed-underneath refusal would be new
161
+ # semantics the spec does not authorize. The rebuilt payload then
162
+ # carries THIS session as owner while inheriting the NEW owner's
163
+ # delegate list, merging two lock identities into one record. The
164
+ # window is a takeover landing between the pre-guard read and the
165
+ # guarded re-read; no test covers it, and closing it means guarding
166
+ # takeover itself, which this intent's scope does not authorize.
167
+ data = with_write_guard(intent_dir, type: type) do
168
+ record = read(intent_dir, type: type) || existing
169
+ rebuilt = payload(session: session, type: type, host: host, now: now,
170
+ delegates: Array(record["delegates"]),
171
+ delegate_activity: Array(record["delegate_activity"]),
172
+ harness: merged_value(harness, record["owner_harness"]),
173
+ agent: merged_value(agent, record["owner_agent"]),
174
+ model: merged_value(model, record["owner_model"]),
175
+ thread: merged_value(thread, record["owner_thread"]),
176
+ run_mode: merged_value(run_mode, record["run_mode"]))
177
+ write(intent_dir, rebuilt, type: type)
178
+ rebuilt
179
+ end
134
180
  return [:owned, data]
135
181
  end
136
182
  return [:held, existing] if fresh?(intent_dir, type: type, ttl: ttl, now: now)
@@ -203,27 +249,32 @@ module Lock
203
249
  # lock. Only the OWNER may delegate; delegates cannot re-delegate.
204
250
  def add_delegate(intent_dir, delegate:, session:, type: "delivery", now: Time.now,
205
251
  harness: nil, agent: nil, model: nil, thread: nil)
206
- data = read(intent_dir, type: type)
207
252
  return false if blank?(delegate)
208
- return false unless data && data["owner_session"].to_s == session.to_s
209
- data["delegates"] = (Array(data["delegates"]) + [delegate.to_s]).uniq
210
- activity = Array(data["delegate_activity"])
211
- previous = activity.find { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
212
- activity.reject! { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
213
- record = {
214
- "session" => delegate.to_s,
215
- "status" => "active",
216
- "registered_at" => now.utc.iso8601,
217
- "last_activity_at" => now.utc.iso8601,
218
- "harness" => merged_value(harness, previous && previous["harness"]),
219
- "agent" => merged_value(agent, previous && previous["agent"]),
220
- "model" => merged_value(model, previous && previous["model"]),
221
- "thread" => merged_value(thread, previous && previous["thread"]),
222
- }
223
- data["delegate_activity"] = bounded_delegate_activity(activity + [record],
224
- delegates: data["delegates"])
225
- write(intent_dir, data, type: type)
226
- true
253
+ # The read moves inside the guard (spec D5): a guard around the write
254
+ # alone still loses updates, since both writers already read the stale
255
+ # copy before contending for the guard.
256
+ with_write_guard(intent_dir, type: type) do
257
+ data = read(intent_dir, type: type)
258
+ next false unless data && data["owner_session"].to_s == session.to_s
259
+ data["delegates"] = (Array(data["delegates"]) + [delegate.to_s]).uniq
260
+ activity = Array(data["delegate_activity"])
261
+ previous = activity.find { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
262
+ activity.reject! { |record| record.is_a?(Hash) && record["session"].to_s == delegate.to_s }
263
+ record = {
264
+ "session" => delegate.to_s,
265
+ "status" => "active",
266
+ "registered_at" => now.utc.iso8601,
267
+ "last_activity_at" => now.utc.iso8601,
268
+ "harness" => merged_value(harness, previous && previous["harness"]),
269
+ "agent" => merged_value(agent, previous && previous["agent"]),
270
+ "model" => merged_value(model, previous && previous["model"]),
271
+ "thread" => merged_value(thread, previous && previous["thread"]),
272
+ }
273
+ data["delegate_activity"] = bounded_delegate_activity(activity + [record],
274
+ delegates: data["delegates"])
275
+ write(intent_dir, data, type: type)
276
+ true
277
+ end
227
278
  end
228
279
 
229
280
  # Activity metadata is observational only. Finishing or failing a delegate
@@ -231,21 +282,23 @@ module Lock
231
282
  def update_delegate_status(intent_dir, delegate:, status:, session:, type: "delivery",
232
283
  now: Time.now)
233
284
  return false unless (DELEGATE_STATUSES - ["active"]).include?(status.to_s)
234
- data = read(intent_dir, type: type)
235
- return false unless data && data["owner_session"].to_s == session.to_s
236
- activity = Array(data["delegate_activity"])
237
- index = activity.index do |record|
238
- record.is_a?(Hash) && record["session"].to_s == delegate.to_s
285
+ with_write_guard(intent_dir, type: type) do
286
+ data = read(intent_dir, type: type)
287
+ next false unless data && data["owner_session"].to_s == session.to_s
288
+ activity = Array(data["delegate_activity"])
289
+ index = activity.index do |record|
290
+ record.is_a?(Hash) && record["session"].to_s == delegate.to_s
291
+ end
292
+ next false unless index
293
+ activity[index] = activity[index].merge(
294
+ "status" => status.to_s,
295
+ "last_activity_at" => now.utc.iso8601
296
+ )
297
+ data["delegate_activity"] = bounded_delegate_activity(activity,
298
+ delegates: data["delegates"])
299
+ write(intent_dir, data, type: type)
300
+ true
239
301
  end
240
- return false unless index
241
- activity[index] = activity[index].merge(
242
- "status" => status.to_s,
243
- "last_activity_at" => now.utc.iso8601
244
- )
245
- data["delegate_activity"] = bounded_delegate_activity(activity,
246
- delegates: data["delegates"])
247
- write(intent_dir, data, type: type)
248
- true
249
302
  end
250
303
 
251
304
  # Owner releases the lock (disarm / End tail, D6). force: true is the repair
@@ -289,11 +342,107 @@ module Lock
289
342
  [:taken, data]
290
343
  end
291
344
 
292
- # Rewrite the lock file in place (owner-side mutations). A content write also
293
- # refreshes the mtime, which is correct: every sanctioned mutation is owner
294
- # activity.
345
+ # Bounded mutual exclusion for the read-modify-write callers of write (spec
346
+ # D3, D5): acquire's re-acquire branch, add_delegate, and
347
+ # update_delegate_status each read, modify, then write, and the guard must
348
+ # span the whole sequence, not just the write, or two writers that already
349
+ # read the stale copy before contending still lose an update.
350
+ #
351
+ # NOT re-entrant: it opens a fresh file descriptor on every call, and flock
352
+ # is scoped to the open file description, so a nested acquisition from the
353
+ # same process would conflict with its own outer hold and burn the whole
354
+ # timeout budget on every single write. write must never take this guard
355
+ # itself; only the three read-modify-write call sites do.
356
+ #
357
+ # Fails open on every edge, per Plastic's fail-open doctrine (intent 93 D7,
358
+ # 111, 112): when the guard file cannot be opened, and when the flock
359
+ # cannot be won inside guard_timeout, the block still runs and its value is
360
+ # still returned. The write still happens, still atomically; the worst case
361
+ # on timeout is exactly today's possibly-lost update, never a torn file and
362
+ # never a hang.
363
+ #
364
+ # The guard file must never be deleted, by anyone, ever. Unlinking an
365
+ # flock target is the unlink-recreate race: a later opener would create a
366
+ # fresh inode and stop serializing against the current holder, breaking
367
+ # mutual exclusion exactly when contention is highest. scripts/write-config:59
368
+ # leaves config.yml.lock in place for the same reason. A leftover guard
369
+ # file is inert: nothing reads it, it stays zero bytes, and
370
+ # scripts/end-intent:558 keys its exit contract on Lock.path alone. This is
371
+ # a requirement, not a guarantee the code enforces: the curator stray-file
372
+ # rule (skills/conventions/references/maintenance-and-revisions.md:155)
373
+ # has no *.lock carve-out today, so a wrongful delete there degrades one
374
+ # write window to the pre-fix (unguarded) behavior, not a hard failure.
375
+ def with_write_guard(intent_dir, type: "delivery",
376
+ guard_timeout: WRITE_GUARD_TIMEOUT_SECONDS,
377
+ guard_retry: WRITE_GUARD_RETRY_SECONDS)
378
+ handle = begin
379
+ File.open(write_guard_path(intent_dir, type: type), File::CREAT | File::RDWR, 0o644)
380
+ rescue SystemCallError
381
+ nil
382
+ end
383
+ return yield unless handle
384
+
385
+ begin
386
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + guard_timeout
387
+ begin
388
+ until handle.flock(File::LOCK_EX | File::LOCK_NB)
389
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
390
+ sleep guard_retry
391
+ end
392
+ rescue SystemCallError
393
+ # File#flock RAISES (does not return false) for every errno except
394
+ # EWOULDBLOCK, so on a filesystem where flock is unsupported (NFS,
395
+ # SMB, some FUSE mounts) this loop raises instead of just failing to
396
+ # win the lock. An flock we cannot take must degrade to an
397
+ # unguarded but still atomic write, same as the File.open rescue
398
+ # above, or this method breaks its own fail-open promise.
399
+ nil
400
+ end
401
+ yield
402
+ ensure
403
+ begin
404
+ handle.flock(File::LOCK_UN)
405
+ rescue SystemCallError
406
+ nil
407
+ end
408
+ begin
409
+ handle.close
410
+ rescue SystemCallError, IOError
411
+ nil
412
+ end
413
+ end
414
+ end
415
+
416
+ # Rewrite the lock file in place (owner-side mutations): write-to-sibling-
417
+ # temp plus File.rename, never an in-place truncate. POSIX rename is an
418
+ # atomic replace, so a concurrent reader always observes either the
419
+ # complete previous content or the complete new content, never a partial
420
+ # file. The temp file is a SIBLING in the same directory as the target,
421
+ # never a system tmpdir, because File.rename can raise EXDEV when the temp
422
+ # and the target live on different filesystems.
423
+ #
424
+ # A content write also refreshes the mtime, which is correct: every
425
+ # sanctioned mutation is owner activity. File.rename carries the temp
426
+ # file's mtime onto the target, so the post-rename mtime is "now", exactly
427
+ # what fresh? (above) and the hook heartbeats already assume. The rename
428
+ # also swaps the inode, which is safe here: heartbeat touches by path
429
+ # through FileUtils.touch, not by handle, and nothing in the repo holds an
430
+ # open handle on delivery.lock across a write.
431
+ #
432
+ # This method does NOT take the write guard itself. with_write_guard is not
433
+ # re-entrant, so a nested acquisition from the same process would conflict
434
+ # with its own outer hold and burn the entire timeout budget on every
435
+ # write, including the hot heartbeat path. The three read-modify-write
436
+ # callers take the guard around the whole sequence instead; write stays a
437
+ # bare atomic replace.
295
438
  def write(intent_dir, data, type: "delivery")
296
- File.write(path(intent_dir, type: type), JSON.pretty_generate(data))
439
+ target = path(intent_dir, type: type)
440
+ temp = write_temp_path(intent_dir, type: type)
441
+ File.write(temp, JSON.pretty_generate(data))
442
+ File.rename(temp, target)
443
+ rescue StandardError
444
+ File.delete(temp) if temp && File.exist?(temp)
445
+ raise
297
446
  end
298
447
 
299
448
  # Read-only normalized inspection. The lock file and its mtime remain the
@@ -0,0 +1,60 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Which ruby would a spawned Plastic hook actually get, and what version is it?
5
+ # Intent 235, D6. Pure and dependency injected: the capture seam is a lambda, so
6
+ # tests never spawn a process and never touch ENV.
7
+ #
8
+ # A hook is launched by the agent application, not by a login shell, so a version
9
+ # manager that activates on shell prompt render (mise, rbenv, asdf) may never reach
10
+ # it and bare `ruby` can still resolve to the system interpreter. Doctor reports
11
+ # that. It never repairs it.
12
+ #
13
+ # Mechanism, and why it is honest:
14
+ # - The command word is the bare name "ruby", so the operating system resolves it
15
+ # on the inherited PATH exactly the way it does for a spawned bash launcher. We
16
+ # do not read PATH ourselves and we do not reimplement the search.
17
+ # - The resolved interpreter answers for itself: RUBY_VERSION is its own version,
18
+ # RbConfig.ruby is its own absolute path. One spawn, no guessing.
19
+ # - RUBYOPT is cleared, matching what every Plastic launcher now does. That makes
20
+ # this the honest simulation of the post-fix world, and it stops the probe from
21
+ # crashing on the exact machine that most needs the report (an old ruby plus a
22
+ # shell that exports RUBYOPT=--yjit).
23
+ module RubyProbe
24
+ module_function
25
+
26
+ # A hash passed as the first argument MERGES onto the inherited environment. It
27
+ # clears nothing unless the key is present with a nil or empty value, so the nil
28
+ # here is load bearing.
29
+ CLEARED_ENV = { "RUBYOPT" => nil }.freeze
30
+
31
+ PROBE_ARGS = ["-rrbconfig", "-e", "puts RUBY_VERSION; puts RbConfig.ruby"].freeze
32
+
33
+ def default_capture
34
+ lambda do |env, command, *args|
35
+ require "open3"
36
+ out, _err, status = Open3.capture3(env, command, *args)
37
+ [out, status.success?]
38
+ rescue Errno::ENOENT
39
+ ["", false] # no ruby on PATH: undetectable, fail open
40
+ end
41
+ end
42
+
43
+ # => { found: true, version: "3.3.5", path: "/opt/ruby/bin/ruby" }
44
+ # => { found: false, version: nil, path: nil } on any trouble at all.
45
+ def resolve(capture: default_capture)
46
+ out, ok = capture.call(CLEARED_ENV, "ruby", *PROBE_ARGS)
47
+ return not_found unless ok
48
+
49
+ version, path = out.to_s.lines.map(&:strip).reject(&:empty?)
50
+ return not_found if version.nil? || version.empty?
51
+
52
+ { found: true, version: version, path: path }
53
+ rescue StandardError
54
+ not_found
55
+ end
56
+
57
+ def not_found
58
+ { found: false, version: nil, path: nil }
59
+ end
60
+ end