@zalom/plastic 1.14.1 → 2.0.0-alpha.1

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 (189) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/config_asks.yml +38 -0
  8. package/hooks/capture +4 -0
  9. package/hooks/close +5 -0
  10. package/hooks/hooks.json +9 -49
  11. package/hooks/record +4 -0
  12. package/package.json +3 -2
  13. package/scripts/agent-report +8 -9
  14. package/scripts/append-ledger +195 -0
  15. package/scripts/codex-hook +78 -90
  16. package/scripts/dashboard.rb +4 -4
  17. package/scripts/doctor.rb +106 -43
  18. package/scripts/end-intent +77 -76
  19. package/scripts/exec-worktree +11 -20
  20. package/scripts/file-session-intent +61 -0
  21. package/scripts/hook-capture +242 -0
  22. package/scripts/hook-close +34 -0
  23. package/scripts/hook-record +149 -0
  24. package/scripts/hook-session-start +95 -12
  25. package/scripts/install.rb +8 -6
  26. package/scripts/lib/agent_models.rb +1 -7
  27. package/scripts/lib/arm.rb +283 -0
  28. package/scripts/lib/backfill_intent.rb +316 -0
  29. package/scripts/lib/bridge.rb +24 -1597
  30. package/scripts/lib/doctor_core.rb +17 -88
  31. package/scripts/lib/doctor_session_ledger.rb +106 -0
  32. package/scripts/lib/exec_worktree.rb +14 -60
  33. package/scripts/lib/harness_text.rb +5 -4
  34. package/scripts/lib/hook_registry.rb +44 -107
  35. package/scripts/lib/insights.rb +3 -4
  36. package/scripts/lib/installer_core.rb +19 -30
  37. package/scripts/lib/lock.rb +6 -26
  38. package/scripts/lib/outcome_guard.rb +7 -5
  39. package/scripts/lib/project_validator.rb +30 -1
  40. package/scripts/lib/roadmap_queue.rb +3 -3
  41. package/scripts/lib/roadmap_savepoint.rb +1 -1
  42. package/scripts/lib/rule_catalog.rb +6 -3
  43. package/scripts/lib/savepoint.rb +320 -0
  44. package/scripts/lib/scaffold_intent.rb +13 -179
  45. package/scripts/lib/session_backfill.rb +184 -0
  46. package/scripts/lib/session_close.rb +84 -0
  47. package/scripts/lib/session_git.rb +469 -0
  48. package/scripts/lib/session_ledger.rb +497 -0
  49. package/scripts/lib/store_discovery.rb +1 -1
  50. package/scripts/lib/store_provisioning.rb +1 -1
  51. package/scripts/maintenance-run +7 -7
  52. package/scripts/new-intent +63 -4
  53. package/scripts/plastic-lock +94 -41
  54. package/scripts/promote-session-item +150 -0
  55. package/scripts/rebuild-graph +1 -1
  56. package/scripts/roadmap-next +2 -2
  57. package/scripts/rollback.rb +42 -3
  58. package/scripts/scaffold-intent +37 -63
  59. package/scripts/session-commit +116 -0
  60. package/scripts/spawn-preamble +5 -5
  61. package/scripts/update.rb +7 -3
  62. package/skills/agent-advisor/SKILL.md +3 -3
  63. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  64. package/skills/auto/SKILL.md +218 -317
  65. package/skills/auto/evals/evals.json +10 -10
  66. package/skills/auto/references/agent-architecture.md +102 -90
  67. package/skills/auto/references/agent-report-contract.md +4 -21
  68. package/skills/auto/references/end-tail.md +47 -53
  69. package/skills/auto/references/human-report-contract.md +8 -8
  70. package/skills/conventions/SKILL.md +1 -3
  71. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  72. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  73. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  74. package/skills/dashboard/SKILL.md +1 -1
  75. package/skills/direct/SKILL.md +66 -0
  76. package/skills/direct/references/request-signals.md +59 -0
  77. package/skills/doctor/SKILL.md +58 -11
  78. package/skills/doctor/report.md +3 -1
  79. package/skills/install/SKILL.md +11 -9
  80. package/skills/intent-continuing/SKILL.md +131 -89
  81. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  82. package/skills/intent-continuing/references/context-management.md +2 -2
  83. package/skills/intent-creating/SKILL.md +2 -2
  84. package/skills/intent-creating/references/lifecycle.md +1 -1
  85. package/skills/intent-ending/SKILL.md +28 -37
  86. package/skills/intent-ending/evals/evals.json +4 -4
  87. package/skills/intent-executing/SKILL.md +12 -49
  88. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  89. package/skills/intent-speccing/SKILL.md +108 -51
  90. package/skills/intent-speccing/evals/evals.json +5 -5
  91. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  92. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  93. package/skills/project-creating/SKILL.md +1 -1
  94. package/skills/releasing/SKILL.md +7 -8
  95. package/skills/tutorial/SKILL.md +2 -2
  96. package/skills/tutorial/references/track-1-guided.md +16 -18
  97. package/skills/tutorial/references/track-2-auto.md +12 -13
  98. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  99. package/templates/agents.md +4 -7
  100. package/templates/project.yml +7 -0
  101. package/templates/roadmap.md +1 -1
  102. package/templates/savepoint.md +2 -2
  103. package/templates/session-intent.md +29 -0
  104. package/agents/plastic-brainstorming.md +0 -48
  105. package/agents/plastic-future-intent-researcher.md +0 -35
  106. package/agents/plastic-intent-curator.md +0 -47
  107. package/agents/plastic-intent-discovery.md +0 -43
  108. package/agents/plastic-planner.md +0 -56
  109. package/agents/plastic-spec-specialist.md +0 -53
  110. package/hooks/auto-arm +0 -5
  111. package/hooks/bash-gate +0 -3
  112. package/hooks/continue +0 -31
  113. package/hooks/edit-gates +0 -3
  114. package/hooks/future-intent-check +0 -25
  115. package/hooks/gate-check +0 -12
  116. package/hooks/power-tools +0 -8
  117. package/scripts/hook-auto-arm +0 -51
  118. package/scripts/hook-bash-gate +0 -78
  119. package/scripts/hook-code-gate +0 -38
  120. package/scripts/hook-continue +0 -48
  121. package/scripts/hook-create-gate +0 -51
  122. package/scripts/hook-edit-gates +0 -58
  123. package/scripts/hook-future-intent-check +0 -90
  124. package/scripts/hook-gate-check +0 -169
  125. package/scripts/hook-links-gate +0 -54
  126. package/scripts/hook-lock-gate +0 -47
  127. package/scripts/hook-power-tools +0 -38
  128. package/scripts/hook-savepoint-pre +0 -32
  129. package/scripts/lib/codex_edit_gates.rb +0 -138
  130. package/scripts/lib/edit_gates.rb +0 -398
  131. package/scripts/lib/links_gate.rb +0 -140
  132. package/scripts/lib/qmd_hook.rb +0 -41
  133. package/scripts/lib/spec_header.rb +0 -83
  134. package/scripts/lib/start_intent.rb +0 -296
  135. package/scripts/start-intent +0 -89
  136. package/skills/_active-intent-gate.md +0 -26
  137. package/skills/auto/references/tiers.md +0 -100
  138. package/skills/continuing/SKILL.md +0 -34
  139. package/skills/continuing/evals/evals.json +0 -91
  140. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  141. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  142. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  143. package/skills/intent-brainstorming/SKILL.md +0 -118
  144. package/skills/intent-brainstorming/evals/evals.json +0 -67
  145. package/skills/intent-continuing/evals/evals.json +0 -145
  146. package/skills/intent-discovering/SKILL.md +0 -56
  147. package/skills/intent-grilling/SKILL.md +0 -108
  148. package/skills/intent-linking/SKILL.md +0 -128
  149. package/skills/intent-linking/evals/evals.json +0 -22
  150. package/skills/intent-linking/references/zettelkasten.md +0 -45
  151. package/skills/intent-locking/SKILL.md +0 -64
  152. package/skills/intent-planning/SKILL.md +0 -145
  153. package/skills/intent-planning/evals/evals.json +0 -81
  154. package/skills/intent-planning/references/plan-format.md +0 -130
  155. package/skills/intent-researching/SKILL.md +0 -123
  156. package/skills/intent-researching/evals/evals.json +0 -22
  157. package/skills/intent-savepoint/SKILL.md +0 -83
  158. package/skills/intent-savepoint/references/context-management.md +0 -32
  159. package/skills/intent-starting/SKILL.md +0 -151
  160. package/skills/intent-starting/evals/evals.json +0 -117
  161. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  162. package/skills/project-continuing/SKILL.md +0 -119
  163. package/skills/project-continuing/evals/evals.json +0 -100
  164. package/skills/roadmap-continuing/SKILL.md +0 -89
  165. package/skills/roadmap-continuing/evals/evals.json +0 -82
  166. package/skills/skill-creating/SKILL.md +0 -75
  167. package/skills/skill-creating/evals/evals.json +0 -108
  168. package/skills/skill-creating/references/agents.md +0 -168
  169. package/skills/skill-creating/references/defaults-first.md +0 -23
  170. package/skills/skill-creating/references/evals.md +0 -41
  171. package/skills/skill-creating/references/hooks.md +0 -251
  172. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  173. package/skills/skill-creating/references/scripts.md +0 -166
  174. package/skills/skill-creating/references/skills.md +0 -169
  175. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  176. package/skills/skill-evaluating/SKILL.md +0 -141
  177. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  178. package/skills/skill-evaluating/evals/evals.json +0 -75
  179. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  180. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  181. package/skills/store-curating/SKILL.md +0 -64
  182. package/skills/store-curating/evals/evals.json +0 -38
  183. package/skills/store-indexing/SKILL.md +0 -93
  184. package/skills/store-indexing/evals/evals.json +0 -22
  185. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  186. package/skills/store-provisioning/SKILL.md +0 -55
  187. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  188. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  189. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -26,7 +26,9 @@ class Doctor
26
26
  "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
27
27
  }.freeze
28
28
 
29
- CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
29
+ # The Claude events hooks_registered expects in settings.json: the five-event map of
30
+ # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301).
31
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd].freeze
30
32
 
31
33
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
32
34
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -40,9 +42,8 @@ class Doctor
40
42
  folgezettel-id
41
43
  read-config
42
44
  hook-session-start
43
- hook-continue
44
- hook-future-intent-check
45
- hook-gate-check
45
+ hook-capture
46
+ hook-record
46
47
  validate-intent
47
48
  doctor.rb
48
49
  ].freeze
@@ -329,7 +330,7 @@ class Doctor
329
330
  hooks_dir = File.join(agent_dir, "hooks")
330
331
 
331
332
  # Derived from HookRegistry.events (intent 204), not a hand-kept list, so
332
- # every registered hook (all 15, including the enforcement gates) is checked.
333
+ # every registered hook is checked.
333
334
  expected_launchers = HookRegistry.claude_launcher_names
334
335
 
335
336
  # hooks_exist
@@ -396,12 +397,6 @@ class Doctor
396
397
  )
397
398
  end
398
399
 
399
- # claude_hooks_implemented (intent 244): the edit-path gates now live as
400
- # branches inside one dispatcher (scripts/hook-edit-gates), which is
401
- # exactly the shape that let links-gate ship registered and dead on
402
- # Codex (intent 200's failure class, one level up).
403
- checks << claude_hooks_implemented_check
404
-
405
400
  # hooks_registered — settings.json has Plastic hooks for required events
406
401
  settings_path = File.join(agent_dir, "settings.json")
407
402
  settings = read_json_safe(settings_path)
@@ -456,7 +451,7 @@ class Doctor
456
451
 
457
452
  # hooks_match_registry (intent 108, D7): the live settings must carry
458
453
  # EXACTLY the registrations HookRegistry defines; any drift (a missing
459
- # gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
454
+ # hook, a stray plastic hook, a stale matcher) is how a hook shipped
460
455
  # dead once already.
461
456
  expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
462
457
  diffs = []
@@ -569,71 +564,6 @@ class Doctor
569
564
  )
570
565
  end
571
566
 
572
- def claude_dispatcher_gate_names(source)
573
- case_start = source.index(/^\s*case gate\b/)
574
- return nil unless case_start
575
-
576
- case_body = source[case_start..-1]
577
- end_idx = case_body.index(/^\s*end\b/)
578
- scanned = end_idx ? case_body[0...end_idx] : case_body
579
- names = scanned.scan(/^\s*when\s+"([^"]+)"/).flatten.uniq
580
- names.empty? ? nil : names
581
- end
582
-
583
- def claude_hooks_implemented_check
584
- dispatcher_path = File.join(plastic_home, "scripts", "hook-edit-gates")
585
-
586
- unless File.exist?(dispatcher_path)
587
- return check(
588
- category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
589
- message: "scripts/hook-edit-gates not found at #{tilde(dispatcher_path)}; cannot verify " \
590
- "the edit-path gate registry and dispatcher agree",
591
- fixable: true, fix_hint: "Re-run the Plastic installer"
592
- )
593
- end
594
-
595
- dispatcher_names = claude_dispatcher_gate_names(File.read(dispatcher_path))
596
-
597
- if dispatcher_names.nil?
598
- return check(
599
- category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
600
- message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the `case gate` " \
601
- "statement no longer matches the shape this check expects, so the registry was " \
602
- "never actually checked against the real dispatcher. Update " \
603
- "claude_dispatcher_gate_names in doctor.rb to the file's new shape.",
604
- fixable: false
605
- )
606
- end
607
-
608
- registry_names = HookRegistry::GATE_TOOLS.keys
609
- missing_branch = registry_names - dispatcher_names
610
- dead_branch = dispatcher_names - registry_names
611
-
612
- if missing_branch.empty? && dead_branch.empty?
613
- return check(
614
- category: "agent_registration", name: "claude_hooks_implemented", status: "pass",
615
- message: "Every registered edit-path gate has a scripts/hook-edit-gates branch, and every " \
616
- "dispatcher branch is registered"
617
- )
618
- end
619
-
620
- details = missing_branch.map do |name|
621
- "#{name} is in HookRegistry::GATE_TOOLS but scripts/hook-edit-gates has no branch for it, " \
622
- "so the dispatcher skips it on every edit and it never blocks anything"
623
- end
624
- details += dead_branch.map do |name|
625
- "#{name} has a branch in scripts/hook-edit-gates but is not in HookRegistry::GATE_TOOLS, so " \
626
- "the dispatcher never reaches it and it is dead code"
627
- end
628
-
629
- check(
630
- category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
631
- message: "The edit-path gate registry and scripts/hook-edit-gates disagree on " \
632
- "#{details.size} gate(s)",
633
- details: details, fixable: false
634
- )
635
- end
636
-
637
567
  def flat_skills_check(agent_dir, installer_flag)
638
568
  skills_root = File.join(agent_dir, "skills")
639
569
  found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
@@ -907,22 +837,21 @@ class Doctor
907
837
  # by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
908
838
  # 203, in the opposite direction). This check closes both directions at once.
909
839
 
910
- # The Codex gate names HookRegistry actually registers: the six apply_patch-gated
911
- # names (CODEX_PRE_HOOKS + CODEX_POST_HOOKS), the two Bash-matcher shell gates
912
- # (CODEX_BASH_HOOKS), and the live-state hook names Codex inherits whole from
913
- # `events` (CODEX_LIVE_STATE_EVENTS). No parsing needed: these are HookRegistry's
914
- # own Ruby constants.
840
+ # The Codex hook names HookRegistry actually registers: the apply_patch record
841
+ # hook (CODEX_POST_HOOKS) and the live-state hook names Codex inherits whole from
842
+ # `events` (CODEX_LIVE_STATE_EVENTS), plus the SessionEnd close hook (intent 309,
843
+ # CODEX_SESSION_END_HOOKS). The PreToolUse gate names left in 2.0 (intent 302). No
844
+ # parsing needed: these are HookRegistry's own Ruby constants.
915
845
  def codex_registry_gate_names
916
846
  live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
917
847
  HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
918
848
  end
919
- (HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
920
- HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
849
+ (HookRegistry::CODEX_POST_HOOKS + live_state + HookRegistry::CODEX_SESSION_END_HOOKS).uniq
921
850
  end
922
851
 
923
852
  def codex_dispatcher_gate_names(source)
924
853
  names = []
925
- %w[STATE_HOOKS SHELL_HOOKS].each do |const|
854
+ %w[STATE_HOOKS].each do |const|
926
855
  m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
927
856
  names.concat(m[1].split(/\s+/)) if m
928
857
  end
@@ -960,7 +889,7 @@ class Doctor
960
889
  return check(
961
890
  category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
962
891
  message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the " \
963
- "STATE_HOOKS/SHELL_HOOKS constants and the `case gate` statement no longer " \
892
+ "STATE_HOOKS constant and the `case gate` statement no longer " \
964
893
  "match the shape this check expects, so the registry could not be checked " \
965
894
  "against the real dispatcher. This is exactly the silent-pass failure this " \
966
895
  "check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
@@ -1005,8 +934,8 @@ class Doctor
1005
934
  toml = File.read(config_toml) rescue ""
1006
935
  warns = []
1007
936
  # guide Part 3: `codex_hooks` is a deprecated alias for `[features] hooks`; catch both.
1008
- warns << "hooks are disabled ([features] hooks = false); Plastic gates will not fire" if toml.match?(/^\s*(?:codex_)?hooks\s*=\s*false/)
1009
- warns << "sandbox_mode = \"read-only\"; apply_patch writes (and gates) cannot run" if toml.match?(/^\s*sandbox_mode\s*=\s*["']read-only["']/)
937
+ warns << "hooks are disabled ([features] hooks = false); Plastic hooks will not fire" if toml.match?(/^\s*(?:codex_)?hooks\s*=\s*false/)
938
+ warns << "sandbox_mode = \"read-only\"; apply_patch writes (and the record hook) cannot run" if toml.match?(/^\s*sandbox_mode\s*=\s*["']read-only["']/)
1010
939
  return nil if warns.empty?
1011
940
 
1012
941
  check(
@@ -0,0 +1,106 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require_relative "session_ledger"
6
+
7
+ # DoctorSessionLedger (intent 308) - the `session_ledger` doctor category, mixed into
8
+ # Doctor. Two checks over the batch-1 store areas that Doctor's intent walker never
9
+ # visits (both are dot-prefixed, and the walker stays ignorant of them by design: see
10
+ # test/store_walker_compat_test.rb). They live in the GLOBAL store only: every writer of
11
+ # .tmp/ and .sessions/ (hook-capture, hook-record, hook-session-start, Arm, SessionClose)
12
+ # targets the global store, so a project scope gets no finding and no check.
13
+ #
14
+ # Read-only: the orphan check names directories and never removes one, because a
15
+ # heartbeat is only refreshed on prompts and edits, so an old heartbeat is evidence, not
16
+ # proof, that the session is gone. Fail-open: an unreadable directory is skipped, never
17
+ # raised. Depends on the including class for `plastic_home` and `check`.
18
+ module DoctorSessionLedger
19
+ ORPHAN_TTL_SECONDS = 24 * 60 * 60
20
+
21
+ # Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
22
+ # else that file's mtime, else the directory's mtime.
23
+ def heartbeat_age(dir, now)
24
+ heartbeat = File.join(dir, "heartbeat")
25
+ if File.file?(heartbeat)
26
+ begin
27
+ return now - Time.iso8601(File.read(heartbeat).strip)
28
+ rescue ArgumentError, IOError, SystemCallError
29
+ return now - File.mtime(heartbeat)
30
+ end
31
+ end
32
+ now - File.mtime(dir)
33
+ end
34
+
35
+ def check_session_ledger(scopes: nil, now: Time.now)
36
+ return [] unless scopes.nil? || scopes.include?("global")
37
+
38
+ store_dir = File.join(plastic_home, "store")
39
+ orphans = orphaned_session_dirs(store_dir, now)
40
+ shape = day_ledger_shape_problems(store_dir)
41
+
42
+ checks = []
43
+ checks << if orphans.empty?
44
+ check(category: "session_ledger", name: "orphaned_session_tmp", status: "pass",
45
+ message: "No .tmp/<session>/ directory has a heartbeat older than " \
46
+ "#{ORPHAN_TTL_SECONDS / 3600} hours")
47
+ else
48
+ check(category: "session_ledger", name: "orphaned_session_tmp", status: "warn",
49
+ message: "#{orphans.size} .tmp/<session>/ director#{orphans.size == 1 ? "y" : "ies"} " \
50
+ "with a heartbeat older than #{ORPHAN_TTL_SECONDS / 3600} hours " \
51
+ "(the session close hook never ran)",
52
+ details: orphans, fixable: true,
53
+ fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
54
+ "session is gone: a live session rewrites its heartbeat on every " \
55
+ "prompt and edit, so only a listed directory may be removed, by hand.")
56
+ end
57
+ checks << if shape.empty?
58
+ check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
59
+ message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
60
+ else
61
+ check(category: "session_ledger", name: "day_ledger_shape", status: "warn",
62
+ message: "#{shape.size} .sessions/ shape problem#{shape.size == 1 ? "" : "s"}",
63
+ details: shape, fixable: true,
64
+ fix_hint: "For a day directory missing its <day>.md, run " \
65
+ "`ruby ~/.plastic/scripts/file-session-intent --day <day> --carry-to " \
66
+ "<today> --store <store>`; rename or remove an entry that is not a " \
67
+ "YYYYMMDD day directory.")
68
+ end
69
+ checks
70
+ end
71
+
72
+ def orphaned_session_dirs(store_dir, now)
73
+ tmp_root = SessionLedger.tmp_root(store_dir)
74
+ return [] unless File.directory?(tmp_root)
75
+
76
+ Dir.children(tmp_root).sort.filter_map do |name|
77
+ dir = File.join(tmp_root, name)
78
+ next unless File.directory?(dir)
79
+
80
+ age = heartbeat_age(dir, now)
81
+ next if age <= ORPHAN_TTL_SECONDS
82
+
83
+ "global: #{dir} (session #{name}, last heartbeat #{(age / 3600).floor}h ago)"
84
+ end
85
+ rescue SystemCallError
86
+ [] # unreadable .tmp/: nothing to report, never a crash
87
+ end
88
+
89
+ def day_ledger_shape_problems(store_dir)
90
+ root = SessionLedger.sessions_root(store_dir)
91
+ return [] unless File.directory?(root)
92
+
93
+ Dir.children(root).sort.filter_map do |name|
94
+ path = File.join(root, name)
95
+ if !File.directory?(path)
96
+ "global: .sessions/#{name} is not a directory (only day directories belong here)"
97
+ elsif !SessionLedger.valid_day_id?(name)
98
+ "global: .sessions/#{name} is not a YYYYMMDD day id"
99
+ elsif !File.file?(SessionLedger.day_file(store_dir, name))
100
+ "global: .sessions/#{name} has no #{name}.md day file"
101
+ end
102
+ end
103
+ rescue SystemCallError
104
+ [] # unreadable .sessions/: nothing to report, never a crash
105
+ end
106
+ end
@@ -3,12 +3,13 @@
3
3
 
4
4
  require "open3"
5
5
  require_relative "bridge"
6
+ require_relative "arm"
6
7
  require_relative "lock"
7
8
  require_relative "worktree"
8
9
  require_relative "scaffold_intent"
9
10
 
10
11
  # ExecWorktree - all logic for `scripts/exec-worktree` (intent 213, group 2). Finish an
11
- # intent's code worktree: an order precondition, then `Worktree.finish` (delivered merges
12
+ # intent's code worktree: the dirty-worktree guard, then `Worktree.finish` (delivered merges
12
13
  # and removes, abandoned removes only).
13
14
  #
14
15
  # THIS IS THE HIGHEST-RISK SURFACE IN THIS ACTION SET because it composes worktree
@@ -21,22 +22,13 @@ require_relative "scaffold_intent"
21
22
  # either of the Lock module's release or takeover operations (that unguarded
22
23
  # read-modify-write gap is parked intent 254's territory).
23
24
  #
24
- # THE ORDER-PRECONDITION TRAP (spec D7, binding). `Bridge.code_gate_decision` returns nil
25
- # UNCONDITIONALLY when the bridge is guided (`build["auto"] != true`), so as a precondition
26
- # it is VACUOUS in guided mode. This module calls that predicate exactly as it exists,
27
- # unmodified, and never copies its "reached How" body into a second predicate (the 200/204
28
- # duplication failure pattern). Instead it states plainly, in the printed output, that on a
29
- # guided bridge the precondition is advisory only, and that the hook layer
30
- # (`Bridge.code_gate_decision`, wired through `scripts/lib/edit_gates.rb`) remains the
31
- # actual enforcement point.
32
- #
33
25
  # Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` directly.
34
26
  # Seams: `runner:` (git, defaults to `Worktree::ShellRunner.new`; also used AFTER finish, for
35
27
  # a delivered disposition, to verify from committed git state that the merge actually landed,
36
28
  # since `Worktree.finish`'s return value is discarded by design and its underlying merge is
37
29
  # fail-open, see the merge-verification note below), `finisher:` (defaults to
38
30
  # `Worktree.method(:finish)`), `status_checker:` (defaults to a `git status --porcelain`
39
- # lambda), `gate:` (defaults to `Bridge.method(:code_gate_decision)`). Every seam exists so
31
+ # lambda). Every seam exists so
40
32
  # tests run with no real git, no real worktree removal, and no real bridge file outside a
41
33
  # tmpdir.
42
34
  #
@@ -58,21 +50,10 @@ module ExecWorktree
58
50
 
59
51
  EXIT_OK = 0
60
52
  EXIT_USAGE = 1
61
- EXIT_PRECONDITION = 2
53
+ # exit 2 was the order precondition; retired in 2.0 (intent 302) and never reused
62
54
  EXIT_NOT_CLEAN = 3
63
55
  EXIT_UNRESOLVED = 4
64
56
 
65
- PROBE_FILENAME = "exec-worktree-precondition-probe"
66
-
67
- # The advisory line, verbatim (spec D7): printed whenever the order precondition
68
- # actually ran (delivered disposition, a code worktree present), on BOTH the pass and
69
- # the fail path, so no reader mistakes this script for the enforcement point.
70
- ADVISORY_LINE =
71
- "This precondition is a friendly early error, not the enforcement point. The hook " \
72
- "layer (scripts/lib/bridge.rb code_gate_decision, wired through " \
73
- "scripts/lib/edit_gates.rb) remains the actual gate. On a guided bridge this " \
74
- "precondition is advisory only."
75
-
76
57
  # --- seams (DI defaults) ----------------------------------------------------------
77
58
 
78
59
  # Real `git status --porcelain` against the code worktree, returning the same
@@ -119,8 +100,7 @@ module ExecWorktree
119
100
  false
120
101
  end
121
102
 
122
- # Session resolution order (mirrors scripts/end-intent:418-426, not
123
- # scripts/start-intent's no-fallback order): explicit --session, else
103
+ # Session resolution order (mirrors scripts/end-intent:418-426): explicit --session, else
124
104
  # CLAUDE_CODE_SESSION_ID (passed in as env_session, never read from ENV here), else
125
105
  # the existing delivery.lock's own recorded owner_session when non-blank, else nil.
126
106
  # The lock-owner fallback is correct here: this is a teardown step on an intent this
@@ -134,7 +114,7 @@ module ExecWorktree
134
114
  Bridge.blank?(owner) ? nil : owner
135
115
  end
136
116
 
137
- # --- result builders (mirror StartIntent's exit_code/stdout/stderr shape) --------
117
+ # --- result builders (the exit_code/stdout/stderr shape the thin CLIs share) --------
138
118
 
139
119
  def usage_result(message)
140
120
  { exit_code: EXIT_USAGE, stderr: ["exec-worktree: #{message}"], stdout: [] }
@@ -156,15 +136,9 @@ module ExecWorktree
156
136
  "recorded owner are all blank); refusing rather than guessing."
157
137
  end
158
138
 
159
- def no_bridge_message(intent_dir, session, id)
160
- "exec-worktree: no bridge resolved for session #{session.inspect} and intent " \
161
- "#{id.inspect} (#{File.basename(intent_dir)}); any worktree this intent provisioned " \
162
- "was NOT removed. Run /plastic-doctor to check for an orphaned worktree."
163
- end
164
-
165
139
  def nothing_provisioned_message(intent_dir)
166
- "exec-worktree: #{File.basename(intent_dir)} has no code worktree recorded on its " \
167
- "bridge (precondition: skipped, no code worktree); nothing was provisioned, nothing " \
140
+ "exec-worktree: #{File.basename(intent_dir)} has no code worktree on disk " \
141
+ "(projects.yml and the intent id resolve none); nothing was provisioned, nothing " \
168
142
  "to finish."
169
143
  end
170
144
 
@@ -195,7 +169,7 @@ module ExecWorktree
195
169
 
196
170
  # --- report (printed only when the run reaches a normal finish) ------------------
197
171
 
198
- def build_report(intent_dir:, disposition:, precondition_status:, worktree_code:, branch:,
172
+ def build_report(intent_dir:, disposition:, worktree_code:, branch:,
199
173
  target_branch:, removed:)
200
174
  # This is only ever reached for "delivered" AFTER `run` has already confirmed, by
201
175
  # reading committed git state (merged_into_current_branch?), that `branch` IS an
@@ -215,7 +189,6 @@ module ExecWorktree
215
189
  [
216
190
  "exec-worktree: #{File.basename(intent_dir)}",
217
191
  " disposition: #{disposition}",
218
- " precondition: #{precondition_status}",
219
192
  " worktree: #{worktree_code}",
220
193
  " branch: #{branch}",
221
194
  " merge: #{merge_line}",
@@ -228,8 +201,7 @@ module ExecWorktree
228
201
  def run(store:, id:, home:, disposition:, session:, env_session:,
229
202
  runner: Worktree::ShellRunner.new,
230
203
  finisher: Worktree.method(:finish),
231
- status_checker: default_status_checker,
232
- gate: Bridge.method(:code_gate_decision))
204
+ status_checker: default_status_checker)
233
205
  return usage_result("--store is required") if Bridge.blank?(store)
234
206
  return usage_result("--id is required") if Bridge.blank?(id)
235
207
  return usage_result("--home is required") if Bridge.blank?(home)
@@ -252,9 +224,7 @@ module ExecWorktree
252
224
  return deny_result(EXIT_UNRESOLVED, no_session_message(intent_dir))
253
225
  end
254
226
 
255
- bridge_data = Bridge.blank?(key_session) ? nil : Bridge.read(key_session, intent_id: id)
256
- return ok_result(no_bridge_message(intent_dir, key_session, id)) if bridge_data.nil?
257
-
227
+ bridge_data = Arm.bridge_hash(intent_dir: intent_dir, home: normalized_home)
258
228
  worktree_code = bridge_data.dig("worktree", "code")
259
229
  return ok_result(nothing_provisioned_message(intent_dir)) if Bridge.blank?(worktree_code)
260
230
 
@@ -264,22 +234,8 @@ module ExecWorktree
264
234
  # both `repo` and `branch` to work with.
265
235
  repo = repo_from_worktree_code(worktree_code)
266
236
 
267
- precondition_ran = false
268
- precondition_status = "not run (abandoned)"
269
-
270
237
  if disposition == "delivered"
271
- precondition_ran = true
272
-
273
- # Step 1: the order precondition. Call Bridge.code_gate_decision AS-IS (never
274
- # relaxed, never duplicated). See the module doc's "order-precondition trap".
275
- probe_path = File.join(worktree_code, PROBE_FILENAME)
276
- reason = gate.call(bridge_data, probe_path, home: normalized_home)
277
- if reason
278
- return deny_result(EXIT_PRECONDITION, [reason, ADVISORY_LINE])
279
- end
280
- precondition_status = bridge_data.dig("build", "auto") == true ? "passed" : "advisory (guided bridge)"
281
-
282
- # Step 2: the dirty-worktree guard, delivered only. Fail CLOSED when the check
238
+ # Step 1: the dirty-worktree guard, delivered only. Fail CLOSED when the check
283
239
  # itself fails or raises: removal force-removes on a plain failure, which would
284
240
  # destroy uncommitted work an inconclusive check could not rule out.
285
241
  out, err, status = status_checker.call(worktree_code)
@@ -290,7 +246,7 @@ module ExecWorktree
290
246
  end
291
247
  end
292
248
 
293
- # Step 3: finish. Worktree.finish is fail-open and never raises: on a merge conflict it
249
+ # Step 2: finish. Worktree.finish is fail-open and never raises: on a merge conflict it
294
250
  # aborts the merge, warns, and STILL removes the worktree (Worktree.merge_branch,
295
251
  # scripts/lib/worktree.rb:246-258). So a failed merge is invisible both in this call's
296
252
  # return value (discarded, as before) and in whether the worktree directory still
@@ -315,11 +271,9 @@ module ExecWorktree
315
271
  end
316
272
 
317
273
  report = build_report(intent_dir: intent_dir, disposition: disposition,
318
- precondition_status: precondition_status,
319
274
  worktree_code: worktree_code, branch: branch,
320
275
  target_branch: target_branch, removed: removed)
321
276
 
322
- stdout = precondition_ran ? [ADVISORY_LINE, report] : [report]
323
- ok_result(stdout)
277
+ ok_result([report])
324
278
  end
325
279
  end
@@ -19,9 +19,10 @@ module HarnessText
19
19
  # form the install_claude hook rewrite already writes.
20
20
  PLUGIN_ROOT_CODEX = "$HOME/.plastic"
21
21
 
22
- # Spec D3: these files document Claude's placeholder convention TO SKILL AUTHORS.
23
- # Rewriting the token there turns true sentences false, so the file is exempt by path.
24
- PLUGIN_ROOT_EXEMPT = ["skill-creating/references/hooks.md"].freeze
22
+ # Spec D3: files that document Claude's placeholder convention TO SKILL AUTHORS are
23
+ # exempt by path. The skill-authoring reference left the installed tree in 2.0
24
+ # (intent 304), so the list is empty; the seam stays for the next such file.
25
+ PLUGIN_ROOT_EXEMPT = [].freeze
25
26
 
26
27
  # Spec D4: an explicit root table, not one regex. The ~/.claude lines in the skill
27
28
  # corpus carry four different meanings; only these three have an exact Codex
@@ -46,7 +47,7 @@ module HarnessText
46
47
  # "~/.claude/hooks/plastic-session-start");
47
48
  # the name alternation, built from the real skills/ directory listing, rejects
48
49
  # anything that is not an installed skill ("/plastic-lock" is a CLI script,
49
- # "/plastic-intent-curator" is an agent role).
50
+ # "/plastic-enforcer" is an agent role).
50
51
  # Longest name first so /plastic-intent-continuing cannot match "continuing".
51
52
  def rewrite_skill_prefix(text, skill_names, prefix)
52
53
  return text if skill_names.nil? || skill_names.empty?