@zalom/plastic 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +23 -8
- package/README.md +16 -5
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-intent-discovery.md +7 -0
- package/bin/plastic.js +17 -8
- package/hooks/auto-arm +2 -2
- package/hooks/bash-gate +1 -1
- package/hooks/check-update +1 -1
- package/hooks/continue +2 -2
- package/hooks/edit-gates +1 -1
- package/hooks/future-intent-check +2 -2
- package/hooks/gate-check +3 -3
- package/hooks/power-tools +1 -1
- package/hooks/session-start +1 -1
- package/package.json +1 -1
- package/scripts/codex-hook +50 -106
- package/scripts/doctor.rb +132 -1080
- package/scripts/exec-worktree +103 -0
- package/scripts/hash-intent +1 -1
- package/scripts/hook-bash-gate +19 -0
- package/scripts/hook-code-gate +4 -1
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +6 -3
- package/scripts/hook-gate-check +17 -0
- package/scripts/hook-links-gate +4 -1
- package/scripts/hook-lock-gate +7 -3
- package/scripts/hook-savepoint-pre +4 -1
- package/scripts/hook-session-start +21 -15
- package/scripts/lib/apply_patch_envelope.rb +46 -13
- package/scripts/lib/bridge.rb +83 -15
- package/scripts/lib/codex_edit_gates.rb +138 -0
- package/scripts/lib/doctor_core.rb +1087 -0
- package/scripts/lib/edit_gates.rb +61 -5
- package/scripts/lib/exec_worktree.rb +325 -0
- package/scripts/lib/harness_text.rb +57 -0
- package/scripts/lib/hook_registry.rb +32 -28
- package/scripts/lib/installer_core.rb +67 -7
- package/scripts/lib/lock.rb +196 -47
- package/scripts/lib/ruby_probe.rb +60 -0
- package/scripts/lib/scaffold_intent.rb +392 -0
- package/scripts/lib/spec_header.rb +83 -0
- package/scripts/lib/start_intent.rb +296 -0
- package/scripts/lib/verify_intent.rb +262 -0
- package/scripts/lib/worktree.rb +15 -1
- package/scripts/link-suggest +1 -1
- package/scripts/maintenance-run +5 -5
- package/scripts/migrate-to-global +2 -2
- package/scripts/restore-intent-v1 +1 -1
- package/scripts/scaffold-intent +120 -0
- package/scripts/start-intent +89 -0
- package/scripts/verify-intent +73 -0
- package/skills/agent-advisor/SKILL.md +5 -5
- package/skills/auto/SKILL.md +42 -32
- package/skills/auto/references/agent-architecture.md +1 -1
- package/skills/auto/references/agent-report-contract.md +1 -1
- package/skills/auto/references/human-report-contract.md +25 -8
- package/skills/auto/references/tiers.md +24 -2
- package/skills/conventions/references/completion-and-done.md +3 -0
- package/skills/conventions/references/gates-and-enforcement.md +28 -12
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/tiers-and-dispatch.md +10 -9
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +6 -5
- package/skills/doctor/references/gates-stuck-detection.md +13 -8
- package/skills/doctor/report.md +1 -1
- package/skills/install/SKILL.md +1 -1
- package/skills/intent-brainstorming/SKILL.md +0 -2
- package/skills/intent-creating/SKILL.md +6 -6
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-discovering/SKILL.md +10 -3
- package/skills/intent-ending/SKILL.md +8 -7
- package/skills/intent-executing/SKILL.md +27 -19
- package/skills/intent-grilling/SKILL.md +5 -3
- package/skills/intent-planning/SKILL.md +7 -3
- package/skills/intent-researching/SKILL.md +0 -2
- package/skills/intent-starting/SKILL.md +10 -2
- package/skills/project-creating/SKILL.md +0 -2
- package/skills/project-creating/references/project-scaffolding.md +1 -1
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/promotion-and-tagging.md +14 -8
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/skill-creating/SKILL.md +5 -2
- package/skills/store-indexing/SKILL.md +8 -5
- package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
- package/skills/tutorial/references/track-1-guided.md +2 -2
- package/skills/tutorial/references/track-2-auto.md +9 -6
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
- package/skills/uninstall/SKILL.md +6 -9
- package/templates/agents.md +12 -12
- package/templates/config.yml +6 -7
- package/templates/index.md +6 -3
- package/templates/spec.md +1 -1
- package/skills/humanizer/SKILL.md +0 -40
- package/skills/humanizer/evals/evals.json +0 -70
- package/skills/humanizer/references/always-on-snippet.md +0 -9
- package/skills/humanizer/references/examples.md +0 -48
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/scripts/lib/lock.rb
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
"
|
|
215
|
-
|
|
216
|
-
"
|
|
217
|
-
"
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
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
|
-
|
|
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
|