@zalom/plastic 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +94 -556
- package/README.md +18 -6
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-intent-curator.md +2 -2
- package/agents/plastic-intent-discovery.md +7 -0
- package/bin/plastic.js +17 -8
- package/deprecations.yml +10 -2
- package/hooks/auto-arm +2 -2
- package/hooks/bash-gate +1 -1
- package/hooks/check-update +1 -1
- package/hooks/continue +2 -2
- package/hooks/edit-gates +3 -0
- package/hooks/future-intent-check +2 -2
- package/hooks/gate-check +3 -3
- package/hooks/hooks.json +4 -44
- package/hooks/power-tools +8 -0
- package/hooks/session-start +1 -1
- package/package.json +1 -2
- package/scripts/codex-hook +57 -118
- package/scripts/doctor.rb +146 -1012
- package/scripts/exec-worktree +103 -0
- package/scripts/hash-intent +1 -1
- package/scripts/hook-bash-gate +19 -0
- package/scripts/hook-code-gate +16 -28
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +13 -57
- package/scripts/hook-edit-gates +58 -0
- package/scripts/hook-gate-check +17 -0
- package/scripts/hook-links-gate +11 -31
- package/scripts/hook-lock-gate +19 -60
- package/scripts/hook-power-tools +38 -0
- package/scripts/hook-savepoint-pre +11 -11
- package/scripts/hook-session-start +21 -15
- package/scripts/lib/apply_patch_envelope.rb +46 -13
- package/scripts/lib/bridge.rb +85 -17
- package/scripts/lib/codex_edit_gates.rb +138 -0
- package/scripts/lib/doctor_core.rb +1087 -0
- package/scripts/lib/edit_gates.rb +398 -0
- package/scripts/lib/exec_worktree.rb +325 -0
- package/scripts/lib/harness_text.rb +57 -0
- package/scripts/lib/hook_registry.rb +64 -28
- package/scripts/lib/installer_core.rb +166 -24
- package/scripts/lib/lock.rb +196 -47
- package/scripts/lib/qmd_hook.rb +21 -41
- package/scripts/lib/qmd_sync.rb +0 -15
- package/scripts/lib/revisions_writer.rb +1 -1
- package/scripts/lib/ruby_probe.rb +60 -0
- package/scripts/lib/scaffold_intent.rb +392 -0
- package/scripts/lib/spec_header.rb +83 -0
- package/scripts/lib/start_intent.rb +296 -0
- package/scripts/lib/verify_intent.rb +262 -0
- package/scripts/lib/worktree.rb +15 -1
- package/scripts/link-suggest +1 -1
- package/scripts/maintenance-run +5 -5
- package/scripts/migrate-to-global +2 -2
- package/scripts/restore-intent-v1 +17 -12
- package/scripts/scaffold-intent +120 -0
- package/scripts/start-intent +89 -0
- package/scripts/verify-intent +73 -0
- package/skills/agent-advisor/SKILL.md +5 -5
- package/skills/auto/SKILL.md +52 -32
- package/skills/auto/evals/evals.json +2 -2
- package/skills/auto/references/agent-architecture.md +1 -1
- package/skills/auto/references/agent-report-contract.md +1 -1
- package/skills/auto/references/human-report-contract.md +22 -3
- package/skills/auto/references/tiers.md +24 -2
- package/skills/conventions/SKILL.md +31 -0
- package/skills/conventions/references/completion-and-done.md +43 -0
- package/skills/conventions/references/gates-and-enforcement.md +39 -0
- package/skills/conventions/references/knowledge-graph.md +47 -0
- package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
- package/skills/conventions/references/locks-and-worktrees.md +113 -0
- package/skills/conventions/references/maintenance-and-revisions.md +170 -0
- package/skills/conventions/references/roadmaps.md +44 -0
- package/skills/conventions/references/tiers-and-dispatch.md +135 -0
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +15 -6
- package/skills/doctor/references/gates-stuck-detection.md +13 -8
- package/skills/doctor/report.md +1 -1
- package/skills/install/SKILL.md +1 -1
- package/skills/intent-brainstorming/SKILL.md +4 -2
- package/skills/intent-continuing/SKILL.md +4 -0
- package/skills/intent-creating/SKILL.md +13 -8
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-discovering/SKILL.md +10 -3
- package/skills/intent-ending/SKILL.md +12 -7
- package/skills/intent-executing/SKILL.md +37 -19
- package/skills/intent-grilling/SKILL.md +5 -3
- package/skills/intent-linking/SKILL.md +4 -0
- package/skills/intent-locking/SKILL.md +4 -0
- package/skills/intent-planning/SKILL.md +14 -3
- package/skills/intent-researching/SKILL.md +0 -2
- package/skills/intent-savepoint/SKILL.md +4 -0
- package/skills/intent-speccing/SKILL.md +4 -0
- package/skills/intent-starting/SKILL.md +20 -2
- package/skills/project-creating/SKILL.md +0 -2
- package/skills/project-creating/references/project-scaffolding.md +3 -3
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/promotion-and-tagging.md +14 -8
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +4 -0
- package/skills/roadmap-continuing/SKILL.md +4 -0
- package/skills/skill-creating/SKILL.md +8 -2
- package/skills/skill-creating/references/defaults-first.md +23 -0
- package/skills/skill-creating/references/hooks.md +4 -1
- package/skills/store-curating/SKILL.md +8 -0
- package/skills/store-indexing/SKILL.md +16 -5
- package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
- package/skills/tutorial/references/track-1-guided.md +2 -2
- package/skills/tutorial/references/track-2-auto.md +10 -8
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
- package/skills/uninstall/SKILL.md +6 -9
- package/templates/agents.md +12 -12
- package/templates/config.yml +6 -7
- package/templates/index.md +6 -3
- package/templates/spec.md +1 -1
- package/PLASTIC-reference.md +0 -138
- package/hooks/code-gate +0 -27
- package/hooks/create-gate +0 -3
- package/hooks/links-gate +0 -3
- package/hooks/lock-gate +0 -21
- package/hooks/qmd-search +0 -8
- package/hooks/retrieval-gate +0 -10
- package/hooks/savepoint-pre +0 -10
- package/scripts/hook-qmd-search +0 -44
- package/scripts/hook-retrieval-gate +0 -148
- package/scripts/lib/retrieval_gate.rb +0 -211
|
@@ -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
|
|
@@ -32,17 +33,20 @@ class InstallerCore
|
|
|
32
33
|
# Regex matching exactly one managed section (BEGIN line .. END line), non-greedy.
|
|
33
34
|
CODEX_SECTION_RE = /^<!-- BEGIN PLASTIC INTEGRATION.*?-->\n.*?\n<!-- END PLASTIC INTEGRATION -->\n?/m
|
|
34
35
|
|
|
35
|
-
# Curated essentials plus a pointer to ~/.plastic/PLASTIC.md
|
|
36
|
-
# ~/.codex/AGENTS.md. Not a slice of PLASTIC.md
|
|
37
|
-
#
|
|
36
|
+
# Curated essentials plus a pointer to ~/.plastic/PLASTIC.md and the plastic-conventions
|
|
37
|
+
# skill, injected into ~/.codex/AGENTS.md. Not a slice of PLASTIC.md itself: AGENTS.md is
|
|
38
|
+
# a shared file Codex merges from multiple sources, so this block stays a small,
|
|
39
|
+
# hand-curated pointer rather than embedding the core wholesale, and it never drifts
|
|
40
|
+
# because it only ever points, never duplicates.
|
|
38
41
|
CODEX_AGENTS_MD_BODY = <<~MD.freeze
|
|
39
42
|
Plastic is installed for this agent. Plastic is intent-driven state management: all
|
|
40
43
|
work flows through an intent, moved through What, Why, How, then Exec. Do not jump
|
|
41
44
|
straight to code.
|
|
42
45
|
|
|
43
46
|
Standing rules:
|
|
44
|
-
-
|
|
45
|
-
|
|
47
|
+
- Core conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly. For
|
|
48
|
+
depth, read a chapter from ~/.agents/skills/plastic-conventions/references/ on demand.
|
|
49
|
+
Both are generated and overwritten on Plastic updates, so never edit them.
|
|
46
50
|
- Operational procedures are installed as skills under ~/.agents/skills/ (each
|
|
47
51
|
plastic-<name>/SKILL.md). Invoke one explicitly as $plastic-<name> (for example
|
|
48
52
|
$plastic-doctor), or let Codex pick one implicitly by matching its description.
|
|
@@ -232,9 +236,17 @@ class InstallerCore
|
|
|
232
236
|
def distribute(mode)
|
|
233
237
|
puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{plastic_home}"
|
|
234
238
|
|
|
239
|
+
manifest_path = File.join(plastic_home, "manifest.json")
|
|
240
|
+
# Capture the prior manifest before the copy loop touches anything, so a
|
|
241
|
+
# file dropped from core_files (renamed/removed) can be pruned below
|
|
242
|
+
# instead of orphaning forever. Mirrors install_for_agent's prune (D14
|
|
243
|
+
# Why-gate correction: this global path never had it).
|
|
244
|
+
old_files = manifest_files(manifest_path)
|
|
245
|
+
|
|
235
246
|
FileUtils.mkdir_p(plastic_home)
|
|
236
247
|
FileUtils.mkdir_p(File.join(plastic_home, "scripts", "lib"))
|
|
237
248
|
FileUtils.mkdir_p(File.join(plastic_home, "templates"))
|
|
249
|
+
FileUtils.mkdir_p(File.join(plastic_home, "hooks"))
|
|
238
250
|
|
|
239
251
|
core_files.each do |src, dest|
|
|
240
252
|
src_path = File.join(package_root, src)
|
|
@@ -248,13 +260,23 @@ class InstallerCore
|
|
|
248
260
|
File.write(File.join(plastic_home, "VERSION"), "#{version}\n")
|
|
249
261
|
|
|
250
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
|
|
251
271
|
|
|
252
272
|
global_files = core_files.values.map { |d| File.join(plastic_home, d) }
|
|
253
273
|
global_files << File.join(plastic_home, "VERSION")
|
|
254
274
|
global_files = global_files.select { |p| File.exist?(p) }
|
|
255
|
-
write_manifest(global_files,
|
|
275
|
+
write_manifest(global_files, manifest_path)
|
|
276
|
+
|
|
277
|
+
pruned = prune_removed_files(old_files - global_files, root: plastic_home)
|
|
256
278
|
|
|
257
|
-
puts " \u{2705} Core files synced (v#{version})"
|
|
279
|
+
puts " \u{2705} Core files synced (v#{version})#{pruned.positive? ? ", #{pruned} stale file(s) pruned" : ""}"
|
|
258
280
|
end
|
|
259
281
|
|
|
260
282
|
# Templates ship in full: every file under templates/ in the repo must reach
|
|
@@ -271,18 +293,35 @@ class InstallerCore
|
|
|
271
293
|
end
|
|
272
294
|
end
|
|
273
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
|
+
|
|
274
314
|
# Files copied into ~/.plastic on install/update. Every verb script + the shared lib
|
|
275
315
|
# must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
|
|
276
316
|
# by install_sync_test). The templates half is glob-derived (template_files above); the
|
|
277
317
|
# rest stays a hand-written literal.
|
|
278
318
|
def core_files
|
|
279
|
-
hand_registered_files.merge(template_files)
|
|
319
|
+
hand_registered_files.merge(template_files).merge(hook_files)
|
|
280
320
|
end
|
|
281
321
|
|
|
282
322
|
def hand_registered_files
|
|
283
323
|
{
|
|
284
324
|
"PLASTIC.md" => "PLASTIC.md",
|
|
285
|
-
"PLASTIC-reference.md" => "PLASTIC-reference.md",
|
|
286
325
|
"deprecations.yml" => "deprecations.yml",
|
|
287
326
|
"config_asks.yml" => "config_asks.yml",
|
|
288
327
|
"scripts/folgezettel-id" => "scripts/folgezettel-id",
|
|
@@ -294,17 +333,16 @@ class InstallerCore
|
|
|
294
333
|
"scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
|
|
295
334
|
"scripts/hook-gate-check" => "scripts/hook-gate-check",
|
|
296
335
|
"scripts/hook-savepoint-pre" => "scripts/hook-savepoint-pre",
|
|
297
|
-
"scripts/hook-
|
|
336
|
+
"scripts/hook-power-tools" => "scripts/hook-power-tools",
|
|
298
337
|
"scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
|
|
299
338
|
"scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
|
|
339
|
+
"scripts/lib/ruby_probe.rb" => "scripts/lib/ruby_probe.rb",
|
|
300
340
|
"scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
|
|
301
341
|
"scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
|
|
302
342
|
"scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
|
|
303
343
|
"scripts/hook-code-gate" => "scripts/hook-code-gate",
|
|
304
344
|
"scripts/hook-lock-gate" => "scripts/hook-lock-gate",
|
|
305
345
|
"scripts/hook-bash-gate" => "scripts/hook-bash-gate",
|
|
306
|
-
"scripts/hook-retrieval-gate" => "scripts/hook-retrieval-gate",
|
|
307
|
-
"scripts/lib/retrieval_gate.rb" => "scripts/lib/retrieval_gate.rb",
|
|
308
346
|
"scripts/hook-auto-arm" => "scripts/hook-auto-arm",
|
|
309
347
|
"scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
|
|
310
348
|
"scripts/lib/lock.rb" => "scripts/lib/lock.rb",
|
|
@@ -345,7 +383,11 @@ class InstallerCore
|
|
|
345
383
|
"scripts/hook-create-gate" => "scripts/hook-create-gate",
|
|
346
384
|
"scripts/hook-links-gate" => "scripts/hook-links-gate",
|
|
347
385
|
"scripts/lib/links_gate.rb" => "scripts/lib/links_gate.rb",
|
|
386
|
+
"scripts/lib/edit_gates.rb" => "scripts/lib/edit_gates.rb",
|
|
387
|
+
"scripts/hook-edit-gates" => "scripts/hook-edit-gates",
|
|
348
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",
|
|
349
391
|
"scripts/codex-hook" => "scripts/codex-hook",
|
|
350
392
|
"scripts/spawn-preamble" => "scripts/spawn-preamble",
|
|
351
393
|
"scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
|
|
@@ -359,7 +401,17 @@ class InstallerCore
|
|
|
359
401
|
"scripts/uninstall.rb" => "scripts/uninstall.rb",
|
|
360
402
|
"scripts/rollback.rb" => "scripts/rollback.rb",
|
|
361
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",
|
|
362
413
|
"scripts/doctor.rb" => "scripts/doctor.rb",
|
|
414
|
+
"scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
|
|
363
415
|
"scripts/dashboard.rb" => "scripts/dashboard.rb",
|
|
364
416
|
"scripts/skill-lint" => "scripts/skill-lint",
|
|
365
417
|
"scripts/lib/skill_lint.rb" => "scripts/lib/skill_lint.rb",
|
|
@@ -403,10 +455,11 @@ class InstallerCore
|
|
|
403
455
|
MD
|
|
404
456
|
|
|
405
457
|
write_if_missing(File.join(plastic_home, "AGENTS.md"), <<~MD)
|
|
406
|
-
# Plastic
|
|
458
|
+
# Plastic: Agent Instructions
|
|
407
459
|
|
|
408
|
-
Read `PLASTIC.md` in this directory
|
|
409
|
-
Follow it exactly. Never modify it
|
|
460
|
+
Read `PLASTIC.md` in this directory for the core conventions; deeper doctrine lives
|
|
461
|
+
in the `plastic-conventions` skill's chapters. Follow it exactly. Never modify it:
|
|
462
|
+
it is overwritten on plugin updates.
|
|
410
463
|
|
|
411
464
|
This file (`AGENTS.md`) is where project-specific rules live.
|
|
412
465
|
|
|
@@ -508,7 +561,8 @@ class InstallerCore
|
|
|
508
561
|
end
|
|
509
562
|
|
|
510
563
|
new_files = manifest_files(manifest_path_for(key, config))
|
|
511
|
-
pruned = prune_removed_files(old_files - new_files
|
|
564
|
+
pruned = prune_removed_files(old_files - new_files,
|
|
565
|
+
root: [config[:dir], config[:home_dir], *shared_fragment_prune_roots])
|
|
512
566
|
result[:pruned] = pruned if pruned.positive?
|
|
513
567
|
|
|
514
568
|
# The legacy manifest is fully superseded once the new one is written; delete it so
|
|
@@ -588,7 +642,8 @@ class InstallerCore
|
|
|
588
642
|
restore_agent(config)
|
|
589
643
|
result = { agent: config[:name], success: false, reason: "verify failed - restored prior snapshot" }
|
|
590
644
|
else
|
|
591
|
-
prune_removed_files(manifest_files(manifest_path_for(key, config))
|
|
645
|
+
prune_removed_files(manifest_files(manifest_path_for(key, config)),
|
|
646
|
+
root: [config[:dir], config[:home_dir], *shared_fragment_prune_roots])
|
|
592
647
|
result = { agent: config[:name], success: false, reason: "verify failed - partial install pruned" }
|
|
593
648
|
end
|
|
594
649
|
end
|
|
@@ -599,10 +654,30 @@ class InstallerCore
|
|
|
599
654
|
|
|
600
655
|
# Delete tracked files present in the old manifest but absent from the new one,
|
|
601
656
|
# then remove any now-empty skill directories they lived in.
|
|
602
|
-
|
|
657
|
+
#
|
|
658
|
+
# `root:` is a mandatory containment boundary (intent 223 F5): every manifest path
|
|
659
|
+
# is read back from JSON written by write_manifest and could, on a hand-edited or
|
|
660
|
+
# otherwise corrupt manifest, name a path outside the install this call is pruning
|
|
661
|
+
# (for example `distribute` prunes against `~/.plastic/`, `install_for_agent` prunes
|
|
662
|
+
# against one agent's own directories). Accepts a single path or an array (an agent
|
|
663
|
+
# with a two-root schema, like Codex's `dir` plus `home_dir`, tracks files under
|
|
664
|
+
# both, so a single boundary would wrongly reject legitimate prunes under the root
|
|
665
|
+
# not passed). A candidate that resolves outside every given root is skipped and
|
|
666
|
+
# never deleted, so a stray manifest entry can never delete a path outside the
|
|
667
|
+
# target home.
|
|
668
|
+
def prune_removed_files(stale_files, root:)
|
|
669
|
+
expanded_roots = Array(root).compact.map { |r| File.expand_path(r) }
|
|
603
670
|
removed = 0
|
|
604
671
|
dirs = []
|
|
605
672
|
stale_files.each do |f|
|
|
673
|
+
if path_contained?(f, store_roots)
|
|
674
|
+
warn " \u{26a0}\u{fe0f} Refused to prune #{f}: inside the intent store"
|
|
675
|
+
next
|
|
676
|
+
end
|
|
677
|
+
unless path_contained?(f, expanded_roots)
|
|
678
|
+
warn " \u{26a0}\u{fe0f} Skipped prune of #{f}: outside #{expanded_roots.join(", ")}"
|
|
679
|
+
next
|
|
680
|
+
end
|
|
606
681
|
if File.exist?(f)
|
|
607
682
|
File.delete(f)
|
|
608
683
|
removed += 1
|
|
@@ -615,6 +690,54 @@ class InstallerCore
|
|
|
615
690
|
removed
|
|
616
691
|
end
|
|
617
692
|
|
|
693
|
+
# True when `path`, once expanded, is one of `roots` itself or lives underneath
|
|
694
|
+
# one of them. String-prefix containment guarded by a trailing separator so a
|
|
695
|
+
# sibling directory that merely shares a prefix (`/home/x-evil` vs root `/home/x`)
|
|
696
|
+
# never counts as contained.
|
|
697
|
+
def path_contained?(path, roots)
|
|
698
|
+
expanded = File.expand_path(path)
|
|
699
|
+
Array(roots).any? { |root| expanded == root || expanded.start_with?(root + File::SEPARATOR) }
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Intent 223 (post-delivery hardening): the user's entire intent history lives under
|
|
703
|
+
# `plastic_home/store` (the global store) and `plastic_home/projects` (every project
|
|
704
|
+
# store). `distribute` legitimately prunes with `root: plastic_home`, which CONTAINS
|
|
705
|
+
# both directories, so the ordinary `root:` containment check in `prune_removed_files`
|
|
706
|
+
# admits a store path instead of refusing it. Today that branch is unreachable (the
|
|
707
|
+
# pruned set is always `old_files - global_files`, both derived from `core_files` plus
|
|
708
|
+
# `VERSION`, so no store path can ever appear there), so this is defense in depth for
|
|
709
|
+
# an unreachable case, not a fix for a live bug. It exists because the standing rule is
|
|
710
|
+
# that NO path ever deletes the global or project intent stores, and the cost of being
|
|
711
|
+
# wrong (irreversible loss of the user's whole intent history) warrants a guard that
|
|
712
|
+
# fails milder than the bug it prevents, independent of what any caller's `root:` is or
|
|
713
|
+
# what a hand-edited manifest claims.
|
|
714
|
+
def store_roots
|
|
715
|
+
[File.join(plastic_home, "store"), File.join(plastic_home, "projects")].map { |p| File.expand_path(p) }
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# `install_skills_flat` relocates any top-level underscore-prefixed markdown
|
|
719
|
+
# fragment (today: `_active-intent-gate.md`, `_decision-tables.md`) out of the
|
|
720
|
+
# per-agent skills tree and into `plastic_home` directly, then lists them in
|
|
721
|
+
# `installed`, so they are manifest-tracked and eligible for prune. But
|
|
722
|
+
# `install_for_agent`'s prune roots are `[config[:dir], config[:home_dir]]`,
|
|
723
|
+
# which do not cover `plastic_home`, so those two files could never actually be
|
|
724
|
+
# pruned (every attempt printed "Skipped prune of ... outside ..." to stderr).
|
|
725
|
+
#
|
|
726
|
+
# Fix scope (intent 223 N3): add each shared fragment's exact FILE path as its
|
|
727
|
+
# own containment root, never `plastic_home` itself as a directory root.
|
|
728
|
+
# `path_contained?` only matches a candidate that equals a root exactly or
|
|
729
|
+
# lives under `root + separator`; a root that is itself a file path can never
|
|
730
|
+
# have children, so listing these exact files as roots permits pruning ONLY
|
|
731
|
+
# those files, never anything else under `plastic_home`. This keeps
|
|
732
|
+
# `~/.plastic/store` and `~/.plastic/projects` (the user's intent stores, never
|
|
733
|
+
# prunable by standing rule) outside every prune root.
|
|
734
|
+
def shared_fragment_prune_roots
|
|
735
|
+
skills_source = File.join(package_root, "skills")
|
|
736
|
+
return [] unless File.directory?(skills_source)
|
|
737
|
+
Dir.children(skills_source).select { |e| e.start_with?("_") && e.end_with?(".md") }
|
|
738
|
+
.map { |e| File.join(plastic_home, e) }
|
|
739
|
+
end
|
|
740
|
+
|
|
618
741
|
def install_claude(config, force, argv: [], input: $stdin, reinstall: false)
|
|
619
742
|
hooks_dir = File.join(config[:dir], "hooks")
|
|
620
743
|
skills_root = File.join(config[:dir], "skills")
|
|
@@ -678,7 +801,16 @@ class InstallerCore
|
|
|
678
801
|
installed = []
|
|
679
802
|
skills_source = File.join(package_root, "skills")
|
|
680
803
|
skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
|
|
681
|
-
|
|
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)
|
|
682
814
|
# Codex-scoped overrides only (agents.models.codex.*): a literal Claude
|
|
683
815
|
# model id set under agents.models.claude.* (or the legacy flat form,
|
|
684
816
|
# which resolves as claude) must never reach a Codex TOML.
|
|
@@ -870,15 +1002,19 @@ class InstallerCore
|
|
|
870
1002
|
# instead, so every skill can read it from one shared location. `exclude` skips
|
|
871
1003
|
# named top-level skill directories entirely (intent 185: the agent-advisor skill
|
|
872
1004
|
# when advisor.enabled is false).
|
|
873
|
-
def install_skills_flat(skills_source, skills_root, exclude: [])
|
|
1005
|
+
def install_skills_flat(skills_source, skills_root, exclude: [], transform: nil)
|
|
874
1006
|
installed = []
|
|
875
1007
|
FileUtils.mkdir_p(skills_root)
|
|
876
1008
|
|
|
877
1009
|
Dir.children(skills_source).reject { |e| e.start_with?(".") || exclude.include?(e) }.each do |entry|
|
|
878
1010
|
src = File.join(skills_source, entry)
|
|
879
1011
|
if File.directory?(src)
|
|
880
|
-
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)
|
|
881
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.
|
|
882
1018
|
FileUtils.mkdir_p(plastic_home)
|
|
883
1019
|
dest = File.join(plastic_home, entry)
|
|
884
1020
|
FileUtils.cp(src, dest)
|
|
@@ -1377,16 +1513,22 @@ class InstallerCore
|
|
|
1377
1513
|
agents.find { |a| a[:key] == key }
|
|
1378
1514
|
end
|
|
1379
1515
|
|
|
1380
|
-
def copy_dir_recursive(src, dest)
|
|
1516
|
+
def copy_dir_recursive(src, dest, transform: nil, rel_prefix: "")
|
|
1381
1517
|
files = []
|
|
1382
1518
|
FileUtils.mkdir_p(dest)
|
|
1383
1519
|
Dir.entries(src).reject { |e| e.start_with?(".") }.each do |entry|
|
|
1384
1520
|
src_path = File.join(src, entry)
|
|
1385
1521
|
dest_path = File.join(dest, entry)
|
|
1522
|
+
rel = rel_prefix.empty? ? entry : File.join(rel_prefix, entry)
|
|
1386
1523
|
if File.directory?(src_path)
|
|
1387
|
-
files += copy_dir_recursive(src_path, dest_path)
|
|
1524
|
+
files += copy_dir_recursive(src_path, dest_path, transform: transform, rel_prefix: rel)
|
|
1388
1525
|
elsif File.file?(src_path)
|
|
1389
|
-
|
|
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
|
|
1390
1532
|
files << dest_path
|
|
1391
1533
|
end
|
|
1392
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
|