@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.
Files changed (127) hide show
  1. package/PLASTIC.md +94 -556
  2. package/README.md +18 -6
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-curator.md +2 -2
  5. package/agents/plastic-intent-discovery.md +7 -0
  6. package/bin/plastic.js +17 -8
  7. package/deprecations.yml +10 -2
  8. package/hooks/auto-arm +2 -2
  9. package/hooks/bash-gate +1 -1
  10. package/hooks/check-update +1 -1
  11. package/hooks/continue +2 -2
  12. package/hooks/edit-gates +3 -0
  13. package/hooks/future-intent-check +2 -2
  14. package/hooks/gate-check +3 -3
  15. package/hooks/hooks.json +4 -44
  16. package/hooks/power-tools +8 -0
  17. package/hooks/session-start +1 -1
  18. package/package.json +1 -2
  19. package/scripts/codex-hook +57 -118
  20. package/scripts/doctor.rb +146 -1012
  21. package/scripts/exec-worktree +103 -0
  22. package/scripts/hash-intent +1 -1
  23. package/scripts/hook-bash-gate +19 -0
  24. package/scripts/hook-code-gate +16 -28
  25. package/scripts/hook-continue +2 -2
  26. package/scripts/hook-create-gate +13 -57
  27. package/scripts/hook-edit-gates +58 -0
  28. package/scripts/hook-gate-check +17 -0
  29. package/scripts/hook-links-gate +11 -31
  30. package/scripts/hook-lock-gate +19 -60
  31. package/scripts/hook-power-tools +38 -0
  32. package/scripts/hook-savepoint-pre +11 -11
  33. package/scripts/hook-session-start +21 -15
  34. package/scripts/lib/apply_patch_envelope.rb +46 -13
  35. package/scripts/lib/bridge.rb +85 -17
  36. package/scripts/lib/codex_edit_gates.rb +138 -0
  37. package/scripts/lib/doctor_core.rb +1087 -0
  38. package/scripts/lib/edit_gates.rb +398 -0
  39. package/scripts/lib/exec_worktree.rb +325 -0
  40. package/scripts/lib/harness_text.rb +57 -0
  41. package/scripts/lib/hook_registry.rb +64 -28
  42. package/scripts/lib/installer_core.rb +166 -24
  43. package/scripts/lib/lock.rb +196 -47
  44. package/scripts/lib/qmd_hook.rb +21 -41
  45. package/scripts/lib/qmd_sync.rb +0 -15
  46. package/scripts/lib/revisions_writer.rb +1 -1
  47. package/scripts/lib/ruby_probe.rb +60 -0
  48. package/scripts/lib/scaffold_intent.rb +392 -0
  49. package/scripts/lib/spec_header.rb +83 -0
  50. package/scripts/lib/start_intent.rb +296 -0
  51. package/scripts/lib/verify_intent.rb +262 -0
  52. package/scripts/lib/worktree.rb +15 -1
  53. package/scripts/link-suggest +1 -1
  54. package/scripts/maintenance-run +5 -5
  55. package/scripts/migrate-to-global +2 -2
  56. package/scripts/restore-intent-v1 +17 -12
  57. package/scripts/scaffold-intent +120 -0
  58. package/scripts/start-intent +89 -0
  59. package/scripts/verify-intent +73 -0
  60. package/skills/agent-advisor/SKILL.md +5 -5
  61. package/skills/auto/SKILL.md +52 -32
  62. package/skills/auto/evals/evals.json +2 -2
  63. package/skills/auto/references/agent-architecture.md +1 -1
  64. package/skills/auto/references/agent-report-contract.md +1 -1
  65. package/skills/auto/references/human-report-contract.md +22 -3
  66. package/skills/auto/references/tiers.md +24 -2
  67. package/skills/conventions/SKILL.md +31 -0
  68. package/skills/conventions/references/completion-and-done.md +43 -0
  69. package/skills/conventions/references/gates-and-enforcement.md +39 -0
  70. package/skills/conventions/references/knowledge-graph.md +47 -0
  71. package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
  72. package/skills/conventions/references/locks-and-worktrees.md +113 -0
  73. package/skills/conventions/references/maintenance-and-revisions.md +170 -0
  74. package/skills/conventions/references/roadmaps.md +44 -0
  75. package/skills/conventions/references/tiers-and-dispatch.md +135 -0
  76. package/skills/dashboard/SKILL.md +1 -1
  77. package/skills/doctor/SKILL.md +15 -6
  78. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  79. package/skills/doctor/report.md +1 -1
  80. package/skills/install/SKILL.md +1 -1
  81. package/skills/intent-brainstorming/SKILL.md +4 -2
  82. package/skills/intent-continuing/SKILL.md +4 -0
  83. package/skills/intent-creating/SKILL.md +13 -8
  84. package/skills/intent-creating/references/lifecycle.md +1 -1
  85. package/skills/intent-discovering/SKILL.md +10 -3
  86. package/skills/intent-ending/SKILL.md +12 -7
  87. package/skills/intent-executing/SKILL.md +37 -19
  88. package/skills/intent-grilling/SKILL.md +5 -3
  89. package/skills/intent-linking/SKILL.md +4 -0
  90. package/skills/intent-locking/SKILL.md +4 -0
  91. package/skills/intent-planning/SKILL.md +14 -3
  92. package/skills/intent-researching/SKILL.md +0 -2
  93. package/skills/intent-savepoint/SKILL.md +4 -0
  94. package/skills/intent-speccing/SKILL.md +4 -0
  95. package/skills/intent-starting/SKILL.md +20 -2
  96. package/skills/project-creating/SKILL.md +0 -2
  97. package/skills/project-creating/references/project-scaffolding.md +3 -3
  98. package/skills/releasing/SKILL.md +1 -1
  99. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +4 -0
  102. package/skills/roadmap-continuing/SKILL.md +4 -0
  103. package/skills/skill-creating/SKILL.md +8 -2
  104. package/skills/skill-creating/references/defaults-first.md +23 -0
  105. package/skills/skill-creating/references/hooks.md +4 -1
  106. package/skills/store-curating/SKILL.md +8 -0
  107. package/skills/store-indexing/SKILL.md +16 -5
  108. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  109. package/skills/tutorial/references/track-1-guided.md +2 -2
  110. package/skills/tutorial/references/track-2-auto.md +10 -8
  111. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  112. package/skills/uninstall/SKILL.md +6 -9
  113. package/templates/agents.md +12 -12
  114. package/templates/config.yml +6 -7
  115. package/templates/index.md +6 -3
  116. package/templates/spec.md +1 -1
  117. package/PLASTIC-reference.md +0 -138
  118. package/hooks/code-gate +0 -27
  119. package/hooks/create-gate +0 -3
  120. package/hooks/links-gate +0 -3
  121. package/hooks/lock-gate +0 -21
  122. package/hooks/qmd-search +0 -8
  123. package/hooks/retrieval-gate +0 -10
  124. package/hooks/savepoint-pre +0 -10
  125. package/scripts/hook-qmd-search +0 -44
  126. package/scripts/hook-retrieval-gate +0 -148
  127. 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, injected into
36
- # ~/.codex/AGENTS.md. Not a slice of PLASTIC.md (which is already over the 32 KiB
37
- # AGENTS.md merge cap on its own), so it never drifts and carries no maintenance fork.
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
- - The full conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly.
45
- It is generated and overwritten on Plastic updates, so never edit it.
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, File.join(plastic_home, "manifest.json"))
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-qmd-search" => "scripts/hook-qmd-search",
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 \u{2014} Agent Instructions
458
+ # Plastic: Agent Instructions
407
459
 
408
- Read `PLASTIC.md` in this directory. It contains all Plastic conventions.
409
- Follow it exactly. Never modify it \u{2014} it is overwritten on plugin updates.
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
- def prune_removed_files(stale_files)
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
- 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)
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
- 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
1390
1532
  files << dest_path
1391
1533
  end
1392
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