@zalom/plastic 1.14.1 → 2.0.0-alpha.2

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 (200) 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/bin/lib/context_budget.rb +453 -0
  8. package/bin/plastic-bench +78 -0
  9. package/config_asks.yml +38 -0
  10. package/hooks/capture +4 -0
  11. package/hooks/close +5 -0
  12. package/hooks/hooks.json +9 -49
  13. package/hooks/record +4 -0
  14. package/hooks/savepoint +5 -5
  15. package/package.json +3 -2
  16. package/scripts/agent-report +8 -9
  17. package/scripts/append-ledger +208 -0
  18. package/scripts/codex-hook +78 -90
  19. package/scripts/dashboard.rb +4 -4
  20. package/scripts/day-summary +53 -0
  21. package/scripts/doctor.rb +106 -43
  22. package/scripts/end-intent +77 -76
  23. package/scripts/exec-worktree +11 -20
  24. package/scripts/file-session-intent +61 -0
  25. package/scripts/hook-capture +242 -0
  26. package/scripts/hook-close +36 -0
  27. package/scripts/hook-record +149 -0
  28. package/scripts/hook-savepoint +45 -0
  29. package/scripts/hook-session-start +106 -12
  30. package/scripts/install.rb +8 -6
  31. package/scripts/lib/agent_models.rb +1 -7
  32. package/scripts/lib/arm.rb +283 -0
  33. package/scripts/lib/backfill_intent.rb +316 -0
  34. package/scripts/lib/bridge.rb +24 -1597
  35. package/scripts/lib/compact_instructions.rb +56 -0
  36. package/scripts/lib/day_summary.rb +206 -0
  37. package/scripts/lib/doctor_core.rb +65 -88
  38. package/scripts/lib/doctor_session_ledger.rb +106 -0
  39. package/scripts/lib/exec_worktree.rb +14 -60
  40. package/scripts/lib/handoff.rb +184 -0
  41. package/scripts/lib/harness_text.rb +5 -4
  42. package/scripts/lib/hook_registry.rb +44 -107
  43. package/scripts/lib/insights.rb +3 -4
  44. package/scripts/lib/installer_core.rb +104 -40
  45. package/scripts/lib/lock.rb +6 -26
  46. package/scripts/lib/outcome_guard.rb +7 -5
  47. package/scripts/lib/project_validator.rb +30 -1
  48. package/scripts/lib/roadmap_queue.rb +3 -3
  49. package/scripts/lib/roadmap_savepoint.rb +1 -1
  50. package/scripts/lib/rule_catalog.rb +6 -3
  51. package/scripts/lib/savepoint.rb +320 -0
  52. package/scripts/lib/scaffold_intent.rb +13 -179
  53. package/scripts/lib/session_backfill.rb +184 -0
  54. package/scripts/lib/session_close.rb +104 -0
  55. package/scripts/lib/session_git.rb +469 -0
  56. package/scripts/lib/session_ledger.rb +497 -0
  57. package/scripts/lib/store_discovery.rb +1 -1
  58. package/scripts/lib/store_provisioning.rb +1 -1
  59. package/scripts/maintenance-run +7 -7
  60. package/scripts/new-intent +63 -4
  61. package/scripts/plastic-lock +94 -41
  62. package/scripts/promote-session-item +150 -0
  63. package/scripts/read-config +3 -0
  64. package/scripts/rebuild-graph +1 -1
  65. package/scripts/roadmap-next +2 -2
  66. package/scripts/rollback.rb +48 -3
  67. package/scripts/scaffold-intent +37 -63
  68. package/scripts/session-commit +116 -0
  69. package/scripts/spawn-preamble +5 -5
  70. package/scripts/update.rb +7 -3
  71. package/scripts/write-handoff +60 -0
  72. package/skills/agent-advisor/SKILL.md +3 -3
  73. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  74. package/skills/auto/SKILL.md +218 -317
  75. package/skills/auto/evals/evals.json +10 -10
  76. package/skills/auto/references/agent-architecture.md +102 -90
  77. package/skills/auto/references/agent-report-contract.md +4 -21
  78. package/skills/auto/references/end-tail.md +47 -53
  79. package/skills/auto/references/human-report-contract.md +8 -8
  80. package/skills/conventions/SKILL.md +1 -3
  81. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  82. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  83. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  84. package/skills/dashboard/SKILL.md +1 -1
  85. package/skills/direct/SKILL.md +66 -0
  86. package/skills/direct/references/request-signals.md +59 -0
  87. package/skills/doctor/SKILL.md +58 -11
  88. package/skills/doctor/report.md +3 -1
  89. package/skills/install/SKILL.md +11 -9
  90. package/skills/intent-continuing/SKILL.md +132 -89
  91. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  92. package/skills/intent-continuing/references/context-management.md +2 -2
  93. package/skills/intent-creating/SKILL.md +2 -2
  94. package/skills/intent-creating/references/lifecycle.md +1 -1
  95. package/skills/intent-ending/SKILL.md +28 -37
  96. package/skills/intent-ending/evals/evals.json +4 -4
  97. package/skills/intent-executing/SKILL.md +12 -49
  98. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  99. package/skills/intent-speccing/SKILL.md +108 -51
  100. package/skills/intent-speccing/evals/evals.json +5 -5
  101. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  102. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  103. package/skills/project-creating/SKILL.md +1 -1
  104. package/skills/releasing/SKILL.md +7 -8
  105. package/skills/tutorial/SKILL.md +2 -2
  106. package/skills/tutorial/references/track-1-guided.md +16 -18
  107. package/skills/tutorial/references/track-2-auto.md +12 -13
  108. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  109. package/templates/agents.md +4 -7
  110. package/templates/config.yml +5 -0
  111. package/templates/project.yml +7 -0
  112. package/templates/roadmap.md +1 -1
  113. package/templates/savepoint.md +2 -2
  114. package/templates/session-intent.md +29 -0
  115. package/agents/plastic-brainstorming.md +0 -48
  116. package/agents/plastic-future-intent-researcher.md +0 -35
  117. package/agents/plastic-intent-curator.md +0 -47
  118. package/agents/plastic-intent-discovery.md +0 -43
  119. package/agents/plastic-planner.md +0 -56
  120. package/agents/plastic-spec-specialist.md +0 -53
  121. package/hooks/auto-arm +0 -5
  122. package/hooks/bash-gate +0 -3
  123. package/hooks/continue +0 -31
  124. package/hooks/edit-gates +0 -3
  125. package/hooks/future-intent-check +0 -25
  126. package/hooks/gate-check +0 -12
  127. package/hooks/power-tools +0 -8
  128. package/scripts/hook-auto-arm +0 -51
  129. package/scripts/hook-bash-gate +0 -78
  130. package/scripts/hook-code-gate +0 -38
  131. package/scripts/hook-continue +0 -48
  132. package/scripts/hook-create-gate +0 -51
  133. package/scripts/hook-edit-gates +0 -58
  134. package/scripts/hook-future-intent-check +0 -90
  135. package/scripts/hook-gate-check +0 -169
  136. package/scripts/hook-links-gate +0 -54
  137. package/scripts/hook-lock-gate +0 -47
  138. package/scripts/hook-power-tools +0 -38
  139. package/scripts/hook-savepoint-pre +0 -32
  140. package/scripts/lib/codex_edit_gates.rb +0 -138
  141. package/scripts/lib/edit_gates.rb +0 -398
  142. package/scripts/lib/links_gate.rb +0 -140
  143. package/scripts/lib/qmd_hook.rb +0 -41
  144. package/scripts/lib/spec_header.rb +0 -83
  145. package/scripts/lib/start_intent.rb +0 -296
  146. package/scripts/start-intent +0 -89
  147. package/skills/_active-intent-gate.md +0 -26
  148. package/skills/auto/references/tiers.md +0 -100
  149. package/skills/continuing/SKILL.md +0 -34
  150. package/skills/continuing/evals/evals.json +0 -91
  151. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  152. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  153. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  154. package/skills/intent-brainstorming/SKILL.md +0 -118
  155. package/skills/intent-brainstorming/evals/evals.json +0 -67
  156. package/skills/intent-continuing/evals/evals.json +0 -145
  157. package/skills/intent-discovering/SKILL.md +0 -56
  158. package/skills/intent-grilling/SKILL.md +0 -108
  159. package/skills/intent-linking/SKILL.md +0 -128
  160. package/skills/intent-linking/evals/evals.json +0 -22
  161. package/skills/intent-linking/references/zettelkasten.md +0 -45
  162. package/skills/intent-locking/SKILL.md +0 -64
  163. package/skills/intent-planning/SKILL.md +0 -145
  164. package/skills/intent-planning/evals/evals.json +0 -81
  165. package/skills/intent-planning/references/plan-format.md +0 -130
  166. package/skills/intent-researching/SKILL.md +0 -123
  167. package/skills/intent-researching/evals/evals.json +0 -22
  168. package/skills/intent-savepoint/SKILL.md +0 -83
  169. package/skills/intent-savepoint/references/context-management.md +0 -32
  170. package/skills/intent-starting/SKILL.md +0 -151
  171. package/skills/intent-starting/evals/evals.json +0 -117
  172. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  173. package/skills/project-continuing/SKILL.md +0 -119
  174. package/skills/project-continuing/evals/evals.json +0 -100
  175. package/skills/roadmap-continuing/SKILL.md +0 -89
  176. package/skills/roadmap-continuing/evals/evals.json +0 -82
  177. package/skills/skill-creating/SKILL.md +0 -75
  178. package/skills/skill-creating/evals/evals.json +0 -108
  179. package/skills/skill-creating/references/agents.md +0 -168
  180. package/skills/skill-creating/references/defaults-first.md +0 -23
  181. package/skills/skill-creating/references/evals.md +0 -41
  182. package/skills/skill-creating/references/hooks.md +0 -251
  183. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  184. package/skills/skill-creating/references/scripts.md +0 -166
  185. package/skills/skill-creating/references/skills.md +0 -169
  186. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  187. package/skills/skill-evaluating/SKILL.md +0 -141
  188. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  189. package/skills/skill-evaluating/evals/evals.json +0 -75
  190. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  191. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  192. package/skills/store-curating/SKILL.md +0 -64
  193. package/skills/store-curating/evals/evals.json +0 -38
  194. package/skills/store-indexing/SKILL.md +0 -93
  195. package/skills/store-indexing/evals/evals.json +0 -22
  196. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  197. package/skills/store-provisioning/SKILL.md +0 -55
  198. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  199. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  200. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -0,0 +1,56 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+
6
+ # The compaction thresholds and the text Plastic installs into ~/.claude/CLAUDE.md
7
+ # (intent 312; intent 296 D35 and D38).
8
+ #
9
+ # One home for the body, because two halves need it: installer_core.rb installs the
10
+ # marked section, doctor_core.rb verifies the installed one is current. Same reason
11
+ # hook_registry.rb is a shared lib rather than a literal duplicated on both sides.
12
+ #
13
+ # The thresholds are absolute token counts, not percentages. From
14
+ # research--context-thresholds.md: models are reliable only to roughly 50 to 65 percent
15
+ # of advertised context, and the mechanisms behind that (lost-in-the-middle, attention
16
+ # dilution, distractor interference) are architectural, so a bigger window does not
17
+ # repeal them. A percentage that is right at 200k, carried to 1M, would let five times
18
+ # as many raw tokens accumulate before it fired.
19
+ #
20
+ # Library only: no CLI, no ENV, no I/O.
21
+ module CompactInstructions
22
+ # 35 and 50 percent of a 1M window.
23
+ OFFER_TOKENS = 350_000
24
+ INSIST_TOKENS = 500_000
25
+
26
+ # Static on purpose. A body rendered from the user's config would change its hash
27
+ # every time they edited config.yml, and doctor would then report a correct install
28
+ # as stale, so the block states the shipped numbers and names the two keys as the
29
+ # override instead. It names the hand-off in words and never by path, so it reads
30
+ # correctly whether or not the hand-off writer is installed.
31
+ BODY = <<~MD.freeze
32
+ Plastic watches this session's context. When the harness reports how much of the
33
+ window is used:
34
+
35
+ - At 350,000 tokens, offer to compact. Say that the hand-off in today's day ledger
36
+ is written and current, and take no for an answer: a task that is nearly done
37
+ does not need the interruption.
38
+ - At 500,000 tokens, insist. Take no new work, write the hand-off in today's day
39
+ ledger, and compact before continuing.
40
+ - After a compaction, say continue. The day summary at boot and the hand-off carry
41
+ the state; do not rebuild it by re-reading files.
42
+
43
+ Both numbers are absolute token counts for a 1M window. `context_offer_tokens` and
44
+ `context_insist_tokens` in `~/.plastic/config.yml` override them.
45
+
46
+ This section is managed by the Plastic installer. It is replaced on update and
47
+ removed on uninstall. Do not edit anything between the BEGIN and END markers.
48
+ MD
49
+
50
+ # The freshness hash the installer stamps into the BEGIN marker, so doctor can tell
51
+ # a current block from one an older version left behind. Same arithmetic as
52
+ # InstallerCore#marked_section, pinned equal by compact_instructions_test.
53
+ def self.body_hash
54
+ Digest::SHA256.hexdigest(BODY)[0, 12]
55
+ end
56
+ end
@@ -0,0 +1,206 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # DaySummary (intent 311): the block SessionStart injects at boot, a bounded
5
+ # rendering of the day ledger (open items, the last five done), the live
6
+ # auto intents (an Active intent with a fresh delivery lock, across the
7
+ # global and every project store), and the other sessions alive by their
8
+ # heartbeat. Never the raw ledger (296 D36). No environment reads; every
9
+ # path is injected.
10
+
11
+ require "time"
12
+ require_relative "session_ledger"
13
+ require_relative "handoff"
14
+ require_relative "lock"
15
+
16
+ module DaySummary
17
+ module_function
18
+
19
+ # Every part at its cap with 80-character summaries is about 2.8 KB; the
20
+ # budget is the safety net above that, not the working limit.
21
+ BUDGET = 3072
22
+ HEARTBEAT_TTL = 3600
23
+ OPEN_CAP = 10
24
+ DONE_CAP = 5
25
+ LIVE_CAP = 5
26
+ SESSIONS_CAP = 10
27
+ LINE_MAX = 100
28
+ # Trimmed first when the budget is exceeded; Open is the last to shrink.
29
+ TRIM_ORDER = %i[others live done open].freeze
30
+ TITLES = {
31
+ open: "Open:",
32
+ done: "Done, last five:",
33
+ live: "Live auto intents:",
34
+ others: "Other active sessions:",
35
+ }.freeze
36
+ ISO8601_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\b/
37
+ INDEX_DIR_RE = %r{store/([\w][\w.-]*?)(?:/|\))}
38
+ private_constant :ISO8601_RE, :INDEX_DIR_RE
39
+
40
+ def build(store:, day:, session:, home:, now: Time.now, heartbeat_ttl: HEARTBEAT_TTL)
41
+ lists = {
42
+ open: open_items(store, day),
43
+ done: last_done(store, day),
44
+ live: live_intents(home, now: now),
45
+ others: active_sessions(store, session, now: now, ttl: heartbeat_ttl),
46
+ }
47
+ hidden = Hash.new(0)
48
+ cap!(lists, hidden, :open, OPEN_CAP, keep: :newest)
49
+ cap!(lists, hidden, :done, DONE_CAP, keep: :newest)
50
+ cap!(lists, hidden, :live, LIVE_CAP, keep: :first)
51
+ cap!(lists, hidden, :others, SESSIONS_CAP, keep: :first)
52
+ return "" if lists.values.all?(&:empty?)
53
+
54
+ loop do
55
+ text = compose(day, lists, hidden)
56
+ return text if text.bytesize <= BUDGET
57
+
58
+ key = TRIM_ORDER.find { |k| !lists[k].empty? }
59
+ return text unless key
60
+
61
+ %i[open done].include?(key) ? lists[key].shift : lists[key].pop
62
+ hidden[key] += 1
63
+ end
64
+ end
65
+
66
+ # --- parts -------------------------------------------------------------------------
67
+
68
+ def open_items(store, day)
69
+ Handoff.read_items(store, day)
70
+ .select { |i| Handoff::OPEN_STATES.include?(i[:state]) }
71
+ .map { |i| "- [#{i[:session]}] [#{i[:project]}] #{Handoff.clip(i[:summary])}" }
72
+ end
73
+
74
+ def last_done(store, day)
75
+ Handoff.read_savepoint(store, day)
76
+ .select { |e| e[:event] == "Done" }
77
+ .map { |e| "- [#{e[:session]}] [#{e[:project]}] #{Handoff.clip(e[:summary])}" }
78
+ end
79
+
80
+ # The global store plus every projects/<slug>/store, each with its INDEX
81
+ # one level up, the way doctor enumerates them.
82
+ def stores(home)
83
+ list = [[File.join(home, "INDEX.md"), File.join(home, "store")]]
84
+ projects_root = File.join(home, "projects")
85
+ if File.directory?(projects_root)
86
+ Dir.children(projects_root).sort.each do |slug|
87
+ list << [File.join(projects_root, slug, "INDEX.md"), File.join(projects_root, slug, "store")]
88
+ end
89
+ end
90
+ list
91
+ end
92
+
93
+ def active_dirs(index_path)
94
+ return [] unless File.exist?(index_path)
95
+
96
+ dirs = []
97
+ current = nil
98
+ File.foreach(index_path) do |line|
99
+ if (m = line.match(/^##\s+(.+?)\s*$/))
100
+ current = m[1]
101
+ next
102
+ end
103
+ next unless current == "Active"
104
+
105
+ line.scan(INDEX_DIR_RE) { |(dirname)| dirs << dirname unless dirs.include?(dirname) }
106
+ end
107
+ dirs
108
+ end
109
+
110
+ def live_intents(home, now:)
111
+ stores(home).flat_map do |index_path, store_dir|
112
+ active_dirs(index_path).filter_map do |dirname|
113
+ dir = File.join(store_dir, dirname)
114
+ next unless File.directory?(dir) && Lock.fresh?(dir, now: now)
115
+ # A guided session's lock is live but not autonomous; a lock with no
116
+ # run_mode (a 1.14 auto team) counts as auto.
117
+ next if (Lock.read(dir) || {})["run_mode"].to_s == "guided"
118
+
119
+ id, slug = dirname.split("--", 2)
120
+ "- #{id} #{slug}: #{last_savepoint_line(dir)}"
121
+ end
122
+ end
123
+ rescue SystemCallError
124
+ []
125
+ end
126
+
127
+ def last_savepoint_line(dir)
128
+ path = File.join(dir, "savepoint.md")
129
+ if File.exist?(path)
130
+ File.readlines(path).reverse_each do |line|
131
+ text = line.strip
132
+ return text[0, LINE_MAX] if text.match?(ISO8601_RE)
133
+ end
134
+ end
135
+ "(no savepoint yet)"
136
+ rescue SystemCallError
137
+ "(no savepoint yet)"
138
+ end
139
+
140
+ def active_sessions(store, session, now:, ttl:)
141
+ tmp_root = SessionLedger.tmp_root(store)
142
+ return [] unless File.directory?(tmp_root)
143
+
144
+ Dir.children(tmp_root).sort.filter_map do |sid|
145
+ next if sid == session || sid.start_with?(".")
146
+
147
+ dir = File.join(tmp_root, sid)
148
+ next unless File.directory?(dir)
149
+
150
+ age = heartbeat_age(dir, now)
151
+ next if age.nil? || age > ttl
152
+
153
+ "- #{sid} (#{(age / 60).floor}m ago, on #{pointer_of(dir)})"
154
+ end
155
+ rescue SystemCallError
156
+ []
157
+ end
158
+
159
+ # Seconds since the session's last heartbeat: the ISO-8601 content of
160
+ # `heartbeat`, else that file's mtime, else the directory's mtime. The
161
+ # same reading doctor's orphan check uses, inverted here for liveness.
162
+ def heartbeat_age(dir, now)
163
+ heartbeat = File.join(dir, "heartbeat")
164
+ if File.file?(heartbeat)
165
+ begin
166
+ return now - Time.iso8601(File.read(heartbeat).strip)
167
+ rescue ArgumentError, IOError, SystemCallError
168
+ return now - File.mtime(heartbeat)
169
+ end
170
+ end
171
+ now - File.mtime(dir)
172
+ rescue SystemCallError
173
+ nil
174
+ end
175
+
176
+ def pointer_of(dir)
177
+ path = File.join(dir, "current")
178
+ return "?" unless File.file?(path)
179
+
180
+ value = File.read(path).strip
181
+ value.empty? ? "?" : value[0, 80]
182
+ rescue SystemCallError
183
+ "?"
184
+ end
185
+
186
+ # --- rendering, pure -------------------------------------------------------------
187
+
188
+ def cap!(lists, hidden, key, cap, keep:)
189
+ return unless lists[key].size > cap
190
+
191
+ hidden[key] += lists[key].size - cap
192
+ lists[key] = keep == :newest ? lists[key].last(cap) : lists[key].first(cap)
193
+ end
194
+
195
+ def compose(day, lists, hidden)
196
+ out = ["Day summary #{day}:"]
197
+ TITLES.each do |key, title|
198
+ next if lists[key].empty? && hidden[key].zero?
199
+
200
+ out << title
201
+ out.concat(lists[key])
202
+ out << "(+#{hidden[key]} more)" if hidden[key].positive?
203
+ end
204
+ out.join("\n")
205
+ end
206
+ end
@@ -15,6 +15,7 @@ require "digest"
15
15
  require "rubygems"
16
16
 
17
17
  require_relative "hook_registry"
18
+ require_relative "compact_instructions"
18
19
 
19
20
  class Doctor
20
21
  DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
@@ -26,7 +27,9 @@ class Doctor
26
27
  "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
27
28
  }.freeze
28
29
 
29
- CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
30
+ # The Claude events hooks_registered expects in settings.json: the five-event map of
31
+ # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301).
32
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd].freeze
30
33
 
31
34
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
32
35
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -40,9 +43,8 @@ class Doctor
40
43
  folgezettel-id
41
44
  read-config
42
45
  hook-session-start
43
- hook-continue
44
- hook-future-intent-check
45
- hook-gate-check
46
+ hook-capture
47
+ hook-record
46
48
  validate-intent
47
49
  doctor.rb
48
50
  ].freeze
@@ -329,7 +331,7 @@ class Doctor
329
331
  hooks_dir = File.join(agent_dir, "hooks")
330
332
 
331
333
  # Derived from HookRegistry.events (intent 204), not a hand-kept list, so
332
- # every registered hook (all 15, including the enforcement gates) is checked.
334
+ # every registered hook is checked.
333
335
  expected_launchers = HookRegistry.claude_launcher_names
334
336
 
335
337
  # hooks_exist
@@ -396,12 +398,6 @@ class Doctor
396
398
  )
397
399
  end
398
400
 
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
401
  # hooks_registered — settings.json has Plastic hooks for required events
406
402
  settings_path = File.join(agent_dir, "settings.json")
407
403
  settings = read_json_safe(settings_path)
@@ -456,7 +452,7 @@ class Doctor
456
452
 
457
453
  # hooks_match_registry (intent 108, D7): the live settings must carry
458
454
  # EXACTLY the registrations HookRegistry defines; any drift (a missing
459
- # gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
455
+ # hook, a stray plastic hook, a stale matcher) is how a hook shipped
460
456
  # dead once already.
461
457
  expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
462
458
  diffs = []
@@ -513,9 +509,56 @@ class Doctor
513
509
  # agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
514
510
  checks << flat_agents_check(agent_dir, "--claude")
515
511
 
512
+ # compact-instructions block in CLAUDE.md (intent 312)
513
+ checks << claude_compact_instructions_check(agent_dir)
514
+
516
515
  checks
517
516
  end
518
517
 
518
+ # Claude CLAUDE.md marker literals. Keep in sync with
519
+ # InstallerCore::CLAUDE_SECTION_BEGIN_PREFIX / CLAUDE_SECTION_END (doctor does not
520
+ # require installer_core, so the literals are duplicated, exactly as for Codex). The
521
+ # BODY and its hash are NOT duplicated: they come from the shared CompactInstructions.
522
+ CLAUDE_COMPACT_BEGIN_PREFIX = "<!-- BEGIN PLASTIC COMPACT"
523
+ CLAUDE_COMPACT_END = "<!-- END PLASTIC COMPACT -->"
524
+
525
+ # Present, well formed, and current. The Codex AGENTS.md check stops at well formed;
526
+ # this one also compares the hash in the BEGIN marker against the shipped body, so a
527
+ # block an older version left behind is reported rather than trusted.
528
+ def claude_compact_instructions_check(agent_dir)
529
+ claude_md = File.join(agent_dir, "CLAUDE.md")
530
+ name = "claude_compact_instructions"
531
+ hint = "Re-run the Plastic installer with --claude"
532
+
533
+ unless File.exist?(claude_md)
534
+ return check(category: "agent_registration", name: name, status: "fail",
535
+ message: "CLAUDE.md not found at #{tilde(claude_md)}, so the compaction instructions are not installed",
536
+ fixable: true, fix_hint: hint)
537
+ end
538
+
539
+ content = File.read(claude_md)
540
+ b = content.index(CLAUDE_COMPACT_BEGIN_PREFIX)
541
+ e = content.index(CLAUDE_COMPACT_END)
542
+ well_formed = b && e && e > b && content[b...e].include?("-->")
543
+
544
+ unless well_formed
545
+ return check(category: "agent_registration", name: name, status: "fail",
546
+ message: "CLAUDE.md is missing the compact-instructions block or its section is malformed",
547
+ fixable: true, fix_hint: hint)
548
+ end
549
+
550
+ installed_hash = content[b..][/hash:(\w+)/, 1]
551
+ if installed_hash != CompactInstructions.body_hash
552
+ return check(category: "agent_registration", name: name, status: "fail",
553
+ message: "the compact-instructions block in CLAUDE.md is stale " \
554
+ "(hash:#{installed_hash}, current is hash:#{CompactInstructions.body_hash})",
555
+ fixable: true, fix_hint: hint)
556
+ end
557
+
558
+ check(category: "agent_registration", name: name, status: "pass",
559
+ message: "CLAUDE.md carries the current compact-instructions block")
560
+ end
561
+
519
562
  # Unfiltered classification (intent 276, spec Approach table): mode (a)
520
563
  # unowned warns, mode (b) current-but-missing fails, a retired/non-hook
521
564
  # launcher is skipped, a third-party hook stays silent.
@@ -569,71 +612,6 @@ class Doctor
569
612
  )
570
613
  end
571
614
 
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
615
  def flat_skills_check(agent_dir, installer_flag)
638
616
  skills_root = File.join(agent_dir, "skills")
639
617
  found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
@@ -907,22 +885,21 @@ class Doctor
907
885
  # by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
908
886
  # 203, in the opposite direction). This check closes both directions at once.
909
887
 
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.
888
+ # The Codex hook names HookRegistry actually registers: the apply_patch record
889
+ # hook (CODEX_POST_HOOKS) and the live-state hook names Codex inherits whole from
890
+ # `events` (CODEX_LIVE_STATE_EVENTS), plus the SessionEnd close hook (intent 309,
891
+ # CODEX_SESSION_END_HOOKS). The PreToolUse gate names left in 2.0 (intent 302). No
892
+ # parsing needed: these are HookRegistry's own Ruby constants.
915
893
  def codex_registry_gate_names
916
894
  live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
917
895
  HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
918
896
  end
919
- (HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
920
- HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
897
+ (HookRegistry::CODEX_POST_HOOKS + live_state + HookRegistry::CODEX_SESSION_END_HOOKS).uniq
921
898
  end
922
899
 
923
900
  def codex_dispatcher_gate_names(source)
924
901
  names = []
925
- %w[STATE_HOOKS SHELL_HOOKS].each do |const|
902
+ %w[STATE_HOOKS].each do |const|
926
903
  m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
927
904
  names.concat(m[1].split(/\s+/)) if m
928
905
  end
@@ -960,7 +937,7 @@ class Doctor
960
937
  return check(
961
938
  category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
962
939
  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 " \
940
+ "STATE_HOOKS constant and the `case gate` statement no longer " \
964
941
  "match the shape this check expects, so the registry could not be checked " \
965
942
  "against the real dispatcher. This is exactly the silent-pass failure this " \
966
943
  "check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
@@ -1005,8 +982,8 @@ class Doctor
1005
982
  toml = File.read(config_toml) rescue ""
1006
983
  warns = []
1007
984
  # 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["']/)
985
+ warns << "hooks are disabled ([features] hooks = false); Plastic hooks will not fire" if toml.match?(/^\s*(?:codex_)?hooks\s*=\s*false/)
986
+ 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
987
  return nil if warns.empty?
1011
988
 
1012
989
  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