@zalom/plastic 1.14.1 → 2.0.0-alpha.10

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 (213) 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 +14 -42
  13. package/hooks/message-display +81 -0
  14. package/hooks/record +4 -0
  15. package/hooks/savepoint +5 -5
  16. package/package.json +3 -2
  17. package/scripts/agent-report +15 -10
  18. package/scripts/append-ledger +208 -0
  19. package/scripts/codex-hook +78 -90
  20. package/scripts/dashboard.rb +43 -14
  21. package/scripts/day-summary +53 -0
  22. package/scripts/doctor.rb +269 -43
  23. package/scripts/end-intent +170 -76
  24. package/scripts/exec-worktree +11 -20
  25. package/scripts/file-session-intent +61 -0
  26. package/scripts/hook-capture +255 -0
  27. package/scripts/hook-close +36 -0
  28. package/scripts/hook-message-display +74 -0
  29. package/scripts/hook-record +157 -0
  30. package/scripts/hook-savepoint +45 -0
  31. package/scripts/hook-session-start +128 -12
  32. package/scripts/install.rb +8 -6
  33. package/scripts/intent-screen +77 -0
  34. package/scripts/lib/agent_models.rb +1 -7
  35. package/scripts/lib/arm.rb +308 -0
  36. package/scripts/lib/backfill_intent.rb +316 -0
  37. package/scripts/lib/bridge.rb +24 -1597
  38. package/scripts/lib/compact_instructions.rb +56 -0
  39. package/scripts/lib/day_summary.rb +211 -0
  40. package/scripts/lib/doctor_core.rb +66 -88
  41. package/scripts/lib/doctor_session_ledger.rb +158 -0
  42. package/scripts/lib/exec_worktree.rb +14 -60
  43. package/scripts/lib/handoff.rb +184 -0
  44. package/scripts/lib/harness_text.rb +5 -4
  45. package/scripts/lib/hook_registry.rb +57 -106
  46. package/scripts/lib/insights.rb +3 -4
  47. package/scripts/lib/installer_core.rb +136 -41
  48. package/scripts/lib/intent_screen.rb +309 -0
  49. package/scripts/lib/intent_screen_ansi.rb +262 -0
  50. package/scripts/lib/lock.rb +6 -26
  51. package/scripts/lib/message_display.rb +290 -0
  52. package/scripts/lib/outcome_guard.rb +7 -5
  53. package/scripts/lib/project_validator.rb +30 -1
  54. package/scripts/lib/report_screen.rb +648 -0
  55. package/scripts/lib/roadmap_queue.rb +3 -3
  56. package/scripts/lib/roadmap_savepoint.rb +1 -1
  57. package/scripts/lib/rule_catalog.rb +6 -3
  58. package/scripts/lib/savepoint.rb +334 -0
  59. package/scripts/lib/scaffold_intent.rb +13 -179
  60. package/scripts/lib/screen_paint.rb +276 -0
  61. package/scripts/lib/session_backfill.rb +184 -0
  62. package/scripts/lib/session_close.rb +104 -0
  63. package/scripts/lib/session_git.rb +500 -0
  64. package/scripts/lib/session_ledger.rb +621 -0
  65. package/scripts/lib/store_discovery.rb +1 -1
  66. package/scripts/lib/store_provisioning.rb +1 -1
  67. package/scripts/maintenance-run +7 -7
  68. package/scripts/new-intent +63 -4
  69. package/scripts/plastic-lock +101 -41
  70. package/scripts/promote-session-item +150 -0
  71. package/scripts/read-config +3 -0
  72. package/scripts/rebuild-graph +1 -1
  73. package/scripts/report-screen +120 -0
  74. package/scripts/roadmap-next +2 -2
  75. package/scripts/rollback.rb +48 -3
  76. package/scripts/savepoint-note +67 -0
  77. package/scripts/scaffold-intent +37 -63
  78. package/scripts/session-commit +116 -0
  79. package/scripts/spawn-preamble +14 -7
  80. package/scripts/update.rb +7 -3
  81. package/scripts/write-handoff +60 -0
  82. package/skills/agent-advisor/SKILL.md +3 -3
  83. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  84. package/skills/auto/SKILL.md +223 -317
  85. package/skills/auto/evals/evals.json +10 -10
  86. package/skills/auto/references/agent-architecture.md +102 -90
  87. package/skills/auto/references/agent-report-contract.md +4 -21
  88. package/skills/auto/references/end-tail.md +47 -53
  89. package/skills/auto/references/human-report-contract.md +62 -56
  90. package/skills/conventions/SKILL.md +1 -3
  91. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  92. package/skills/conventions/references/locks-and-worktrees.md +62 -58
  93. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  94. package/skills/dashboard/SKILL.md +1 -1
  95. package/skills/direct/SKILL.md +66 -0
  96. package/skills/direct/references/request-signals.md +59 -0
  97. package/skills/doctor/SKILL.md +58 -11
  98. package/skills/doctor/report.md +3 -1
  99. package/skills/install/SKILL.md +11 -9
  100. package/skills/intent-continuing/SKILL.md +142 -91
  101. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  102. package/skills/intent-continuing/references/context-management.md +3 -3
  103. package/skills/intent-creating/SKILL.md +2 -2
  104. package/skills/intent-creating/references/lifecycle.md +1 -1
  105. package/skills/intent-ending/SKILL.md +34 -37
  106. package/skills/intent-ending/evals/evals.json +4 -4
  107. package/skills/intent-executing/SKILL.md +15 -46
  108. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  109. package/skills/intent-speccing/SKILL.md +108 -51
  110. package/skills/intent-speccing/evals/evals.json +5 -5
  111. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  112. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  113. package/skills/project-creating/SKILL.md +1 -1
  114. package/skills/releasing/SKILL.md +7 -8
  115. package/skills/tutorial/SKILL.md +2 -2
  116. package/skills/tutorial/references/track-1-guided.md +16 -18
  117. package/skills/tutorial/references/track-2-auto.md +12 -13
  118. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  119. package/templates/agents.md +4 -7
  120. package/templates/config.yml +5 -0
  121. package/templates/intent-screen.md +17 -0
  122. package/templates/outcome.md +14 -1
  123. package/templates/project.yml +7 -0
  124. package/templates/report-state.md +11 -0
  125. package/templates/roadmap.md +1 -1
  126. package/templates/savepoint.md +2 -2
  127. package/templates/session-intent.md +29 -0
  128. package/agents/plastic-brainstorming.md +0 -48
  129. package/agents/plastic-future-intent-researcher.md +0 -35
  130. package/agents/plastic-intent-curator.md +0 -47
  131. package/agents/plastic-intent-discovery.md +0 -43
  132. package/agents/plastic-planner.md +0 -56
  133. package/agents/plastic-spec-specialist.md +0 -53
  134. package/hooks/auto-arm +0 -5
  135. package/hooks/bash-gate +0 -3
  136. package/hooks/continue +0 -31
  137. package/hooks/edit-gates +0 -3
  138. package/hooks/future-intent-check +0 -25
  139. package/hooks/gate-check +0 -12
  140. package/hooks/power-tools +0 -8
  141. package/scripts/hook-auto-arm +0 -51
  142. package/scripts/hook-bash-gate +0 -78
  143. package/scripts/hook-code-gate +0 -38
  144. package/scripts/hook-continue +0 -48
  145. package/scripts/hook-create-gate +0 -51
  146. package/scripts/hook-edit-gates +0 -58
  147. package/scripts/hook-future-intent-check +0 -90
  148. package/scripts/hook-gate-check +0 -169
  149. package/scripts/hook-links-gate +0 -54
  150. package/scripts/hook-lock-gate +0 -47
  151. package/scripts/hook-power-tools +0 -38
  152. package/scripts/hook-savepoint-pre +0 -32
  153. package/scripts/lib/codex_edit_gates.rb +0 -138
  154. package/scripts/lib/edit_gates.rb +0 -398
  155. package/scripts/lib/links_gate.rb +0 -140
  156. package/scripts/lib/qmd_hook.rb +0 -41
  157. package/scripts/lib/spec_header.rb +0 -83
  158. package/scripts/lib/start_intent.rb +0 -296
  159. package/scripts/start-intent +0 -89
  160. package/skills/_active-intent-gate.md +0 -26
  161. package/skills/auto/references/tiers.md +0 -100
  162. package/skills/continuing/SKILL.md +0 -34
  163. package/skills/continuing/evals/evals.json +0 -91
  164. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  165. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  166. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  167. package/skills/intent-brainstorming/SKILL.md +0 -118
  168. package/skills/intent-brainstorming/evals/evals.json +0 -67
  169. package/skills/intent-continuing/evals/evals.json +0 -145
  170. package/skills/intent-discovering/SKILL.md +0 -56
  171. package/skills/intent-grilling/SKILL.md +0 -108
  172. package/skills/intent-linking/SKILL.md +0 -128
  173. package/skills/intent-linking/evals/evals.json +0 -22
  174. package/skills/intent-linking/references/zettelkasten.md +0 -45
  175. package/skills/intent-locking/SKILL.md +0 -64
  176. package/skills/intent-planning/SKILL.md +0 -145
  177. package/skills/intent-planning/evals/evals.json +0 -81
  178. package/skills/intent-planning/references/plan-format.md +0 -130
  179. package/skills/intent-researching/SKILL.md +0 -123
  180. package/skills/intent-researching/evals/evals.json +0 -22
  181. package/skills/intent-savepoint/SKILL.md +0 -83
  182. package/skills/intent-savepoint/references/context-management.md +0 -32
  183. package/skills/intent-starting/SKILL.md +0 -151
  184. package/skills/intent-starting/evals/evals.json +0 -117
  185. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  186. package/skills/project-continuing/SKILL.md +0 -119
  187. package/skills/project-continuing/evals/evals.json +0 -100
  188. package/skills/roadmap-continuing/SKILL.md +0 -89
  189. package/skills/roadmap-continuing/evals/evals.json +0 -82
  190. package/skills/skill-creating/SKILL.md +0 -75
  191. package/skills/skill-creating/evals/evals.json +0 -108
  192. package/skills/skill-creating/references/agents.md +0 -168
  193. package/skills/skill-creating/references/defaults-first.md +0 -23
  194. package/skills/skill-creating/references/evals.md +0 -41
  195. package/skills/skill-creating/references/hooks.md +0 -251
  196. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  197. package/skills/skill-creating/references/scripts.md +0 -166
  198. package/skills/skill-creating/references/skills.md +0 -169
  199. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  200. package/skills/skill-evaluating/SKILL.md +0 -141
  201. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  202. package/skills/skill-evaluating/evals/evals.json +0 -75
  203. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  204. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  205. package/skills/store-curating/SKILL.md +0 -64
  206. package/skills/store-curating/evals/evals.json +0 -38
  207. package/skills/store-indexing/SKILL.md +0 -93
  208. package/skills/store-indexing/evals/evals.json +0 -22
  209. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  210. package/skills/store-provisioning/SKILL.md +0 -55
  211. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  212. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  213. /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,211 @@
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
+ require_relative "report_screen"
16
+
17
+ module DaySummary
18
+ module_function
19
+
20
+ # Every part at its cap with 80-character summaries is about 2.8 KB; the
21
+ # budget is the safety net above that, not the working limit.
22
+ BUDGET = 3072
23
+ HEARTBEAT_TTL = 3600
24
+ OPEN_CAP = 10
25
+ DONE_CAP = 5
26
+ LIVE_CAP = 5
27
+ SESSIONS_CAP = 10
28
+ LINE_MAX = 100
29
+ # Trimmed first when the budget is exceeded; Open is the last to shrink.
30
+ TRIM_ORDER = %i[others live done open].freeze
31
+ TITLES = {
32
+ open: "Open:",
33
+ done: "Done, last five:",
34
+ live: "Live auto intents:",
35
+ others: "Other active sessions:",
36
+ }.freeze
37
+ ISO8601_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\b/
38
+ INDEX_DIR_RE = %r{store/([\w][\w.-]*?)(?:/|\))}
39
+ private_constant :ISO8601_RE, :INDEX_DIR_RE
40
+
41
+ def build(store:, day:, session:, home:, now: Time.now, heartbeat_ttl: HEARTBEAT_TTL)
42
+ lists = {
43
+ open: open_items(store, day),
44
+ done: last_done(store, day),
45
+ live: live_intents(home, now: now),
46
+ others: active_sessions(store, session, now: now, ttl: heartbeat_ttl),
47
+ }
48
+ hidden = Hash.new(0)
49
+ cap!(lists, hidden, :open, OPEN_CAP, keep: :newest)
50
+ cap!(lists, hidden, :done, DONE_CAP, keep: :newest)
51
+ cap!(lists, hidden, :live, LIVE_CAP, keep: :first)
52
+ cap!(lists, hidden, :others, SESSIONS_CAP, keep: :first)
53
+ return "" if lists.values.all?(&:empty?)
54
+
55
+ loop do
56
+ text = compose(day, lists, hidden)
57
+ return text if text.bytesize <= BUDGET
58
+
59
+ key = TRIM_ORDER.find { |k| !lists[k].empty? }
60
+ return text unless key
61
+
62
+ %i[open done].include?(key) ? lists[key].shift : lists[key].pop
63
+ hidden[key] += 1
64
+ end
65
+ end
66
+
67
+ # --- parts -------------------------------------------------------------------------
68
+
69
+ def open_items(store, day)
70
+ Handoff.read_items(store, day)
71
+ .select { |i| Handoff::OPEN_STATES.include?(i[:state]) }
72
+ .map { |i| "- [#{i[:session]}] [#{i[:project]}] #{Handoff.clip(i[:summary])}" }
73
+ end
74
+
75
+ def last_done(store, day)
76
+ Handoff.read_savepoint(store, day)
77
+ .select { |e| e[:event] == "Done" }
78
+ .map { |e| "- [#{e[:session]}] [#{e[:project]}] #{Handoff.clip(e[:summary])}" }
79
+ end
80
+
81
+ # The global store plus every projects/<slug>/store, each with its INDEX
82
+ # one level up, the way doctor enumerates them.
83
+ def stores(home)
84
+ list = [[File.join(home, "INDEX.md"), File.join(home, "store")]]
85
+ projects_root = File.join(home, "projects")
86
+ if File.directory?(projects_root)
87
+ Dir.children(projects_root).sort.each do |slug|
88
+ list << [File.join(projects_root, slug, "INDEX.md"), File.join(projects_root, slug, "store")]
89
+ end
90
+ end
91
+ list
92
+ end
93
+
94
+ def active_dirs(index_path)
95
+ return [] unless File.exist?(index_path)
96
+
97
+ dirs = []
98
+ current = nil
99
+ File.foreach(index_path) do |line|
100
+ if (m = line.match(/^##\s+(.+?)\s*$/))
101
+ current = m[1]
102
+ next
103
+ end
104
+ next unless current == "Active"
105
+
106
+ line.scan(INDEX_DIR_RE) { |(dirname)| dirs << dirname unless dirs.include?(dirname) }
107
+ end
108
+ dirs
109
+ end
110
+
111
+ def live_intents(home, now:)
112
+ stores(home).flat_map do |index_path, store_dir|
113
+ active_dirs(index_path).filter_map do |dirname|
114
+ dir = File.join(store_dir, dirname)
115
+ next unless File.directory?(dir) && Lock.fresh?(dir, now: now)
116
+ # A guided session's lock is live but not autonomous; a lock with no
117
+ # run_mode (a 1.14 auto team) counts as auto. 317a (B9): when the lock
118
+ # carries no run_mode, the outcome frontmatter stamp (D5) is asked
119
+ # before defaulting, so a guided close never reads as auto.
120
+ run_mode = (Lock.read(dir) || {})["run_mode"].to_s
121
+ run_mode = ReportScreen.outcome_frontmatter(dir)["mode"].to_s if run_mode.empty?
122
+ next if run_mode == "guided"
123
+
124
+ id, slug = dirname.split("--", 2)
125
+ "- #{id} #{slug}: #{last_savepoint_line(dir)}"
126
+ end
127
+ end
128
+ rescue SystemCallError
129
+ []
130
+ end
131
+
132
+ def last_savepoint_line(dir)
133
+ path = File.join(dir, "savepoint.md")
134
+ if File.exist?(path)
135
+ File.readlines(path).reverse_each do |line|
136
+ text = line.strip
137
+ return text[0, LINE_MAX] if text.match?(ISO8601_RE)
138
+ end
139
+ end
140
+ "(no savepoint yet)"
141
+ rescue SystemCallError
142
+ "(no savepoint yet)"
143
+ end
144
+
145
+ def active_sessions(store, session, now:, ttl:)
146
+ tmp_root = SessionLedger.tmp_root(store)
147
+ return [] unless File.directory?(tmp_root)
148
+
149
+ Dir.children(tmp_root).sort.filter_map do |sid|
150
+ next if sid == session || sid.start_with?(".")
151
+
152
+ dir = File.join(tmp_root, sid)
153
+ next unless File.directory?(dir)
154
+
155
+ age = heartbeat_age(dir, now)
156
+ next if age.nil? || age > ttl
157
+
158
+ "- #{sid} (#{(age / 60).floor}m ago, on #{pointer_of(dir)})"
159
+ end
160
+ rescue SystemCallError
161
+ []
162
+ end
163
+
164
+ # Seconds since the session's last heartbeat: the ISO-8601 content of
165
+ # `heartbeat`, else that file's mtime, else the directory's mtime. The
166
+ # same reading doctor's orphan check uses, inverted here for liveness.
167
+ def heartbeat_age(dir, now)
168
+ heartbeat = File.join(dir, "heartbeat")
169
+ if File.file?(heartbeat)
170
+ begin
171
+ return now - Time.iso8601(File.read(heartbeat).strip)
172
+ rescue ArgumentError, IOError, SystemCallError
173
+ return now - File.mtime(heartbeat)
174
+ end
175
+ end
176
+ now - File.mtime(dir)
177
+ rescue SystemCallError
178
+ nil
179
+ end
180
+
181
+ def pointer_of(dir)
182
+ path = File.join(dir, "current")
183
+ return "?" unless File.file?(path)
184
+
185
+ value = File.read(path).strip
186
+ value.empty? ? "?" : value[0, 80]
187
+ rescue SystemCallError
188
+ "?"
189
+ end
190
+
191
+ # --- rendering, pure -------------------------------------------------------------
192
+
193
+ def cap!(lists, hidden, key, cap, keep:)
194
+ return unless lists[key].size > cap
195
+
196
+ hidden[key] += lists[key].size - cap
197
+ lists[key] = keep == :newest ? lists[key].last(cap) : lists[key].first(cap)
198
+ end
199
+
200
+ def compose(day, lists, hidden)
201
+ out = ["Day summary #{day}:"]
202
+ TITLES.each do |key, title|
203
+ next if lists[key].empty? && hidden[key].zero?
204
+
205
+ out << title
206
+ out.concat(lists[key])
207
+ out << "(+#{hidden[key]} more)" if hidden[key].positive?
208
+ end
209
+ out.join("\n")
210
+ end
211
+ 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,10 @@ 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 six-event map of
31
+ # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
32
+ # intent 316a added MessageDisplay, registered for message-display, Claude only).
33
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd MessageDisplay].freeze
30
34
 
31
35
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
32
36
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -40,9 +44,8 @@ class Doctor
40
44
  folgezettel-id
41
45
  read-config
42
46
  hook-session-start
43
- hook-continue
44
- hook-future-intent-check
45
- hook-gate-check
47
+ hook-capture
48
+ hook-record
46
49
  validate-intent
47
50
  doctor.rb
48
51
  ].freeze
@@ -329,7 +332,7 @@ class Doctor
329
332
  hooks_dir = File.join(agent_dir, "hooks")
330
333
 
331
334
  # Derived from HookRegistry.events (intent 204), not a hand-kept list, so
332
- # every registered hook (all 15, including the enforcement gates) is checked.
335
+ # every registered hook is checked.
333
336
  expected_launchers = HookRegistry.claude_launcher_names
334
337
 
335
338
  # hooks_exist
@@ -396,12 +399,6 @@ class Doctor
396
399
  )
397
400
  end
398
401
 
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
402
  # hooks_registered — settings.json has Plastic hooks for required events
406
403
  settings_path = File.join(agent_dir, "settings.json")
407
404
  settings = read_json_safe(settings_path)
@@ -456,7 +453,7 @@ class Doctor
456
453
 
457
454
  # hooks_match_registry (intent 108, D7): the live settings must carry
458
455
  # EXACTLY the registrations HookRegistry defines; any drift (a missing
459
- # gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
456
+ # hook, a stray plastic hook, a stale matcher) is how a hook shipped
460
457
  # dead once already.
461
458
  expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
462
459
  diffs = []
@@ -513,9 +510,56 @@ class Doctor
513
510
  # agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
514
511
  checks << flat_agents_check(agent_dir, "--claude")
515
512
 
513
+ # compact-instructions block in CLAUDE.md (intent 312)
514
+ checks << claude_compact_instructions_check(agent_dir)
515
+
516
516
  checks
517
517
  end
518
518
 
519
+ # Claude CLAUDE.md marker literals. Keep in sync with
520
+ # InstallerCore::CLAUDE_SECTION_BEGIN_PREFIX / CLAUDE_SECTION_END (doctor does not
521
+ # require installer_core, so the literals are duplicated, exactly as for Codex). The
522
+ # BODY and its hash are NOT duplicated: they come from the shared CompactInstructions.
523
+ CLAUDE_COMPACT_BEGIN_PREFIX = "<!-- BEGIN PLASTIC COMPACT"
524
+ CLAUDE_COMPACT_END = "<!-- END PLASTIC COMPACT -->"
525
+
526
+ # Present, well formed, and current. The Codex AGENTS.md check stops at well formed;
527
+ # this one also compares the hash in the BEGIN marker against the shipped body, so a
528
+ # block an older version left behind is reported rather than trusted.
529
+ def claude_compact_instructions_check(agent_dir)
530
+ claude_md = File.join(agent_dir, "CLAUDE.md")
531
+ name = "claude_compact_instructions"
532
+ hint = "Re-run the Plastic installer with --claude"
533
+
534
+ unless File.exist?(claude_md)
535
+ return check(category: "agent_registration", name: name, status: "fail",
536
+ message: "CLAUDE.md not found at #{tilde(claude_md)}, so the compaction instructions are not installed",
537
+ fixable: true, fix_hint: hint)
538
+ end
539
+
540
+ content = File.read(claude_md)
541
+ b = content.index(CLAUDE_COMPACT_BEGIN_PREFIX)
542
+ e = content.index(CLAUDE_COMPACT_END)
543
+ well_formed = b && e && e > b && content[b...e].include?("-->")
544
+
545
+ unless well_formed
546
+ return check(category: "agent_registration", name: name, status: "fail",
547
+ message: "CLAUDE.md is missing the compact-instructions block or its section is malformed",
548
+ fixable: true, fix_hint: hint)
549
+ end
550
+
551
+ installed_hash = content[b..][/hash:(\w+)/, 1]
552
+ if installed_hash != CompactInstructions.body_hash
553
+ return check(category: "agent_registration", name: name, status: "fail",
554
+ message: "the compact-instructions block in CLAUDE.md is stale " \
555
+ "(hash:#{installed_hash}, current is hash:#{CompactInstructions.body_hash})",
556
+ fixable: true, fix_hint: hint)
557
+ end
558
+
559
+ check(category: "agent_registration", name: name, status: "pass",
560
+ message: "CLAUDE.md carries the current compact-instructions block")
561
+ end
562
+
519
563
  # Unfiltered classification (intent 276, spec Approach table): mode (a)
520
564
  # unowned warns, mode (b) current-but-missing fails, a retired/non-hook
521
565
  # launcher is skipped, a third-party hook stays silent.
@@ -569,71 +613,6 @@ class Doctor
569
613
  )
570
614
  end
571
615
 
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
616
  def flat_skills_check(agent_dir, installer_flag)
638
617
  skills_root = File.join(agent_dir, "skills")
639
618
  found = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
@@ -907,22 +886,21 @@ class Doctor
907
886
  # by hand), or a dispatcher branch nobody registers (bash-gate's shape, intent
908
887
  # 203, in the opposite direction). This check closes both directions at once.
909
888
 
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.
889
+ # The Codex hook names HookRegistry actually registers: the apply_patch record
890
+ # hook (CODEX_POST_HOOKS) and the live-state hook names Codex inherits whole from
891
+ # `events` (CODEX_LIVE_STATE_EVENTS), plus the SessionEnd close hook (intent 309,
892
+ # CODEX_SESSION_END_HOOKS). The PreToolUse gate names left in 2.0 (intent 302). No
893
+ # parsing needed: these are HookRegistry's own Ruby constants.
915
894
  def codex_registry_gate_names
916
895
  live_state = HookRegistry::CODEX_LIVE_STATE_EVENTS.flat_map do |event|
917
896
  HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
918
897
  end
919
- (HookRegistry::CODEX_PRE_HOOKS + HookRegistry::CODEX_POST_HOOKS +
920
- HookRegistry::CODEX_BASH_HOOKS + live_state).uniq
898
+ (HookRegistry::CODEX_POST_HOOKS + live_state + HookRegistry::CODEX_SESSION_END_HOOKS).uniq
921
899
  end
922
900
 
923
901
  def codex_dispatcher_gate_names(source)
924
902
  names = []
925
- %w[STATE_HOOKS SHELL_HOOKS].each do |const|
903
+ %w[STATE_HOOKS].each do |const|
926
904
  m = source.match(/^#{const}\s*=\s*%w\[([^\]]*)\]/)
927
905
  names.concat(m[1].split(/\s+/)) if m
928
906
  end
@@ -960,7 +938,7 @@ class Doctor
960
938
  return check(
961
939
  category: "agent_registration", name: "codex_hooks_implemented", status: "fail",
962
940
  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 " \
941
+ "STATE_HOOKS constant and the `case gate` statement no longer " \
964
942
  "match the shape this check expects, so the registry could not be checked " \
965
943
  "against the real dispatcher. This is exactly the silent-pass failure this " \
966
944
  "check exists to prevent; update codex_dispatcher_gate_names in doctor.rb " \
@@ -1005,8 +983,8 @@ class Doctor
1005
983
  toml = File.read(config_toml) rescue ""
1006
984
  warns = []
1007
985
  # 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["']/)
986
+ warns << "hooks are disabled ([features] hooks = false); Plastic hooks will not fire" if toml.match?(/^\s*(?:codex_)?hooks\s*=\s*false/)
987
+ 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
988
  return nil if warns.empty?
1011
989
 
1012
990
  check(
@@ -0,0 +1,158 @@
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
+ # Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
22
+ # DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
23
+ # session start never ran at all (a `-p` print session, a resumed
24
+ # background job, an unregistered SessionStart hook), not a session that
25
+ # ran and then never closed. No choice of session id ever creates a
26
+ # pointer for that class, so it would otherwise stay invisible for the
27
+ # full 24 hours (or forever, once hook-capture/hook-record stop creating
28
+ # it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
29
+ # between its own start and its first pointer write is normal and must not
30
+ # be flagged, but that window is seconds, not hours.
31
+ NO_POINTER_TTL_SECONDS = 5 * 60
32
+
33
+ # Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
34
+ # else that file's mtime, else the directory's mtime.
35
+ def heartbeat_age(dir, now)
36
+ heartbeat = File.join(dir, "heartbeat")
37
+ if File.file?(heartbeat)
38
+ begin
39
+ return now - Time.iso8601(File.read(heartbeat).strip)
40
+ rescue ArgumentError, IOError, SystemCallError
41
+ return now - File.mtime(heartbeat)
42
+ end
43
+ end
44
+ now - File.mtime(dir)
45
+ end
46
+
47
+ def check_session_ledger(scopes: nil, now: Time.now)
48
+ return [] unless scopes.nil? || scopes.include?("global")
49
+
50
+ store_dir = File.join(plastic_home, "store")
51
+ orphans = orphaned_session_dirs(store_dir, now)
52
+ no_pointer = no_pointer_session_dirs(store_dir, now)
53
+ shape = day_ledger_shape_problems(store_dir)
54
+
55
+ checks = []
56
+ checks << if orphans.empty?
57
+ check(category: "session_ledger", name: "orphaned_session_tmp", status: "pass",
58
+ message: "No .tmp/<session>/ directory has a heartbeat older than " \
59
+ "#{ORPHAN_TTL_SECONDS / 3600} hours")
60
+ else
61
+ check(category: "session_ledger", name: "orphaned_session_tmp", status: "warn",
62
+ message: "#{orphans.size} .tmp/<session>/ director#{orphans.size == 1 ? "y" : "ies"} " \
63
+ "with a heartbeat older than #{ORPHAN_TTL_SECONDS / 3600} hours " \
64
+ "(the session close hook never ran)",
65
+ details: orphans, fixable: true,
66
+ fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
67
+ "session is gone: a live session rewrites its heartbeat on every " \
68
+ "prompt and edit, so only a listed directory may be removed, by hand.")
69
+ end
70
+ checks << if no_pointer.empty?
71
+ check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
72
+ message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
73
+ "longer than #{NO_POINTER_TTL_SECONDS} seconds")
74
+ else
75
+ check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
76
+ message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
77
+ "with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
78
+ "(session start never ran for this session)",
79
+ details: no_pointer, fixable: true,
80
+ fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
81
+ "session never started: no `current` pointer means session start never " \
82
+ "ran for it, so this is not a live session missing a checklist entry.")
83
+ end
84
+ checks << if shape.empty?
85
+ check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
86
+ message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
87
+ else
88
+ check(category: "session_ledger", name: "day_ledger_shape", status: "warn",
89
+ message: "#{shape.size} .sessions/ shape problem#{shape.size == 1 ? "" : "s"}",
90
+ details: shape, fixable: true,
91
+ fix_hint: "For a day directory missing its <day>.md, run " \
92
+ "`ruby ~/.plastic/scripts/file-session-intent --day <day> --carry-to " \
93
+ "<today> --store <store>`; rename or remove an entry that is not a " \
94
+ "YYYYMMDD day directory.")
95
+ end
96
+ checks
97
+ end
98
+
99
+ def orphaned_session_dirs(store_dir, now)
100
+ tmp_root = SessionLedger.tmp_root(store_dir)
101
+ return [] unless File.directory?(tmp_root)
102
+
103
+ Dir.children(tmp_root).sort.filter_map do |name|
104
+ dir = File.join(tmp_root, name)
105
+ next unless File.directory?(dir)
106
+
107
+ age = heartbeat_age(dir, now)
108
+ next if age <= ORPHAN_TTL_SECONDS
109
+
110
+ "global: #{dir} (session #{name}, last heartbeat #{(age / 3600).floor}h ago)"
111
+ end
112
+ rescue SystemCallError
113
+ [] # unreadable .tmp/: nothing to report, never a crash
114
+ end
115
+
116
+ # Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
117
+ # NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
118
+ # that one is age-only and blind to whether a pointer exists at all, so a
119
+ # young no-pointer dir (a session between its start and its first pointer
120
+ # write) must not appear here even though it may well appear there once it
121
+ # ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
122
+ # and a dir can legitimately show up in neither, either, or both.
123
+ def no_pointer_session_dirs(store_dir, now)
124
+ tmp_root = SessionLedger.tmp_root(store_dir)
125
+ return [] unless File.directory?(tmp_root)
126
+
127
+ Dir.children(tmp_root).sort.filter_map do |name|
128
+ dir = File.join(tmp_root, name)
129
+ next unless File.directory?(dir)
130
+ next if File.exist?(File.join(dir, "current"))
131
+
132
+ age = heartbeat_age(dir, now)
133
+ next if age <= NO_POINTER_TTL_SECONDS
134
+
135
+ "global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
136
+ end
137
+ rescue SystemCallError
138
+ [] # unreadable .tmp/: nothing to report, never a crash
139
+ end
140
+
141
+ def day_ledger_shape_problems(store_dir)
142
+ root = SessionLedger.sessions_root(store_dir)
143
+ return [] unless File.directory?(root)
144
+
145
+ Dir.children(root).sort.filter_map do |name|
146
+ path = File.join(root, name)
147
+ if !File.directory?(path)
148
+ "global: .sessions/#{name} is not a directory (only day directories belong here)"
149
+ elsif !SessionLedger.valid_day_id?(name)
150
+ "global: .sessions/#{name} is not a YYYYMMDD day id"
151
+ elsif !File.file?(SessionLedger.day_file(store_dir, name))
152
+ "global: .sessions/#{name} has no #{name}.md day file"
153
+ end
154
+ end
155
+ rescue SystemCallError
156
+ [] # unreadable .sessions/: nothing to report, never a crash
157
+ end
158
+ end