@zalom/plastic 2.0.0-alpha.1 → 2.0.0-alpha.11

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 (55) hide show
  1. package/bin/lib/context_budget.rb +453 -0
  2. package/bin/plastic-bench +78 -0
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +81 -0
  5. package/hooks/savepoint +5 -5
  6. package/package.json +1 -1
  7. package/scripts/agent-report +8 -2
  8. package/scripts/append-ledger +16 -3
  9. package/scripts/dashboard.rb +39 -10
  10. package/scripts/day-summary +53 -0
  11. package/scripts/doctor.rb +163 -0
  12. package/scripts/end-intent +93 -0
  13. package/scripts/hook-capture +21 -8
  14. package/scripts/hook-close +3 -1
  15. package/scripts/hook-message-display +74 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-savepoint +45 -0
  18. package/scripts/hook-session-start +34 -1
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/arm.rb +26 -1
  21. package/scripts/lib/compact_instructions.rb +56 -0
  22. package/scripts/lib/day_summary.rb +211 -0
  23. package/scripts/lib/doctor_core.rb +52 -3
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/handoff.rb +184 -0
  26. package/scripts/lib/hook_registry.rb +14 -0
  27. package/scripts/lib/installer_core.rb +117 -11
  28. package/scripts/lib/intent_screen.rb +309 -0
  29. package/scripts/lib/intent_screen_ansi.rb +262 -0
  30. package/scripts/lib/message_display.rb +290 -0
  31. package/scripts/lib/report_screen.rb +671 -0
  32. package/scripts/lib/savepoint.rb +14 -0
  33. package/scripts/lib/screen_paint.rb +276 -0
  34. package/scripts/lib/session_close.rb +22 -2
  35. package/scripts/lib/session_git.rb +49 -18
  36. package/scripts/lib/session_ledger.rb +124 -0
  37. package/scripts/plastic-lock +8 -1
  38. package/scripts/read-config +3 -0
  39. package/scripts/report-screen +157 -0
  40. package/scripts/rollback.rb +6 -0
  41. package/scripts/savepoint-note +67 -0
  42. package/scripts/spawn-preamble +9 -2
  43. package/scripts/write-handoff +60 -0
  44. package/skills/auto/SKILL.md +15 -8
  45. package/skills/auto/references/human-report-contract.md +59 -53
  46. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  47. package/skills/intent-continuing/SKILL.md +31 -22
  48. package/skills/intent-continuing/references/boarding-matrix.md +5 -5
  49. package/skills/intent-continuing/references/context-management.md +1 -1
  50. package/skills/intent-ending/SKILL.md +8 -2
  51. package/skills/intent-executing/SKILL.md +6 -0
  52. package/templates/config.yml +5 -0
  53. package/templates/intent-screen.md +17 -0
  54. package/templates/outcome.md +14 -1
  55. package/templates/report-state.md +11 -0
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # hook-savepoint (intent 311): the PreCompact hook body. Reads the harness
6
+ # payload from stdin (session_id, cwd), takes the Plastic home from argv,
7
+ # writes this session's hand-off for the pointer's day, and prints one
8
+ # message: the fixed text, plus the written file's path when a session
9
+ # resolved (both harnesses relay the same launcher and the Codex pin feeds
10
+ # both sides the same payload, so the two stay equal). Always exits 0,
11
+ # always prints the message.
12
+ #
13
+ # Usage: hook-savepoint <plastic_home> (stdin: the hook JSON)
14
+
15
+ require "json"
16
+ require_relative "lib/session_ledger"
17
+ require_relative "lib/handoff"
18
+
19
+ MESSAGE = "PLASTIC SAVEPOINT - context is being compacted. The hand-off for this " \
20
+ "session is written in today's day ledger " \
21
+ "(store/.sessions/<day>/handoff--<session>.md). " \
22
+ "After compaction say continue to resume from it."
23
+
24
+ written = nil
25
+ begin
26
+ plastic_home = ARGV[0].to_s
27
+ raw = $stdin.tty? ? "" : $stdin.read.to_s
28
+ payload = raw.strip.empty? ? {} : JSON.parse(raw)
29
+ payload = {} unless payload.is_a?(Hash)
30
+ session_id = payload["session_id"].to_s
31
+
32
+ unless plastic_home.empty? || session_id.strip.empty?
33
+ store = File.join(File.expand_path(plastic_home), "store")
34
+ session = SessionLedger.short_session_id(session_id, nil)
35
+ day = Handoff.day_for(store, session, today: SessionLedger.day_id)
36
+ written = Handoff.write(store: store, day: day, session: session, trigger: "precompact",
37
+ templates: File.expand_path("../templates", __dir__))
38
+ end
39
+ rescue StandardError, JSON::ParserError
40
+ nil
41
+ end
42
+
43
+ message = written ? "#{MESSAGE} This session's file: #{written}" : MESSAGE
44
+ puts JSON.generate("systemMessage" => message)
45
+ exit 0
@@ -13,10 +13,29 @@ require_relative "lib/boot_banner"
13
13
  require_relative "lib/qmd_sync"
14
14
  require_relative "lib/doctor_core"
15
15
  require_relative "lib/session_ledger"
16
+ require_relative "lib/day_summary"
16
17
 
17
18
  index_path, plastic_home, mode, plugin_root = ARGV
18
19
  exit 0 unless index_path && plastic_home && mode
19
20
 
21
+ # --- session id: the stdin payload's session_id, then the env var, then the
22
+ # pid (spec 298 D1, row G, spec D4). Guarded on $stdin.tty? so a human running
23
+ # this hook by hand at a real terminal never blocks on a read that never gets
24
+ # an EOF; every real harness invocation pipes the SessionStart JSON payload,
25
+ # never attaches a tty. Malformed or empty stdin (or no payload id) falls
26
+ # through to the same env-var-then-pid chain this always had.
27
+ stdin_payload = begin
28
+ if $stdin.tty?
29
+ nil
30
+ else
31
+ raw = $stdin.read
32
+ raw && !raw.strip.empty? ? JSON.parse(raw) : nil
33
+ end
34
+ rescue StandardError
35
+ nil
36
+ end
37
+ payload_session_id = stdin_payload.is_a?(Hash) ? stdin_payload["session_id"].to_s : ""
38
+
20
39
  # Plastic home and the store are two different paths (intent 231). The shim passes
21
40
  # home (~/.plastic) as argument 2; the store lives one level below it. Compose the
22
41
  # store exactly once here, so no later line re-derives it and no path can gain a
@@ -360,7 +379,11 @@ begin
360
379
  author = "session" if author.empty?
361
380
  SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
362
381
 
363
- session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
382
+ session = if !payload_session_id.empty?
383
+ payload_session_id
384
+ else
385
+ ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
386
+ end
364
387
  sid = SessionLedger.short_session_id(nil, session)
365
388
  SessionLedger.ensure_tmp_root(store_dir)
366
389
  FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
@@ -381,6 +404,16 @@ begin
381
404
  end
382
405
  end
383
406
  parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
407
+
408
+ # The day summary (intent 311, spec D8): open items, the last five done,
409
+ # live auto intents, other active sessions. Never the raw ledger. A
410
+ # failure here leaves the joined line alone.
411
+ begin
412
+ summary = DaySummary.build(store: store_dir, day: day, session: sid, home: plastic_home, now: Time.now)
413
+ parts << summary unless summary.empty?
414
+ rescue StandardError
415
+ nil
416
+ end
384
417
  rescue StandardError
385
418
  nil
386
419
  end
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ # intent-screen (intent 316) - prints the intent screen for one intent directory:
5
+ # the title, the field table (Store, Status, Stage, Savepoint, Progress, Next,
6
+ # Insight, each with a note), and the Steps table, filled from the record by
7
+ # scripts/lib/intent_screen.rb. The session adds the What-this-means bullets and
8
+ # the close; it never edits the numbers.
9
+ #
10
+ # Usage:
11
+ # intent-screen <intent_dir> [--template <path>] [--ansi]
12
+ #
13
+ # The store root is the directory two levels above the intent (<root>/store/<id--slug>);
14
+ # the template defaults to templates/intent-screen.md next to this script's dir,
15
+ # in-repo (<repo>/scripts -> <repo>/templates) and installed (~/.plastic/scripts ->
16
+ # ~/.plastic/templates) alike.
17
+ #
18
+ # --ansi (intent 316a, O3): emits scripts/lib/intent_screen_ansi.rb's styled
19
+ # truecolor block instead of the plain Markdown screen. Plain stays the
20
+ # default with no flag. Two things force plain even WITH --ansi (D18): NO_COLOR
21
+ # present in the environment (any value counts), or a non-TTY stdout — the
22
+ # true default form of D2, not IntentScreenAnsi's own uncoloured layout. The
23
+ # library (scripts/lib/intent_screen_ansi.rb) is pure and never reads either;
24
+ # this script is the one place allowed to.
25
+ #
26
+ # Exit codes:
27
+ # 0 - the screen is on stdout
28
+ # 2 - usage error, or the path is not an intent directory (one line on stderr)
29
+ #
30
+ # Harness-agnostic core: no harness assumption lives here. This script holds
31
+ # the plain/ANSI selection (intent 316a1, D3) and belongs to the same
32
+ # harness-agnostic core as lib/intent_screen.rb and lib/intent_screen_ansi.rb.
33
+
34
+ require_relative "lib/intent_screen"
35
+ require_relative "lib/intent_screen_ansi"
36
+
37
+ def usage_abort(message)
38
+ warn "intent-screen: #{message}"
39
+ exit 2
40
+ end
41
+
42
+ args = ARGV.dup
43
+ template_path = nil
44
+ ansi = false
45
+ positional = []
46
+ while (arg = args.shift)
47
+ case arg
48
+ when "--template"
49
+ template_path = args.shift or usage_abort("--template needs a path")
50
+ when "--ansi"
51
+ ansi = true
52
+ else
53
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
54
+ positional << arg
55
+ end
56
+ end
57
+
58
+ usage_abort("usage: intent-screen <intent_dir> [--template <path>] [--ansi]") unless positional.length == 1
59
+ intent_dir = File.expand_path(positional.first)
60
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
61
+
62
+ store_root = File.expand_path("../..", intent_dir)
63
+ template_path ||= File.expand_path("../templates/intent-screen.md", __dir__)
64
+ usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
65
+
66
+ plain = -> { IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path)) }
67
+
68
+ degrade_to_plain = ENV.key?("NO_COLOR") || !$stdout.tty?
69
+
70
+ $stdout.write(
71
+ if ansi && !degrade_to_plain
72
+ IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true)
73
+ else
74
+ plain.call
75
+ end
76
+ )
77
+ exit 0
@@ -94,8 +94,21 @@ module Arm
94
94
  }
95
95
  end
96
96
 
97
+ # Owner rule 2026-08-31: does this session already have a live top-level
98
+ # pointer pointing SOMEWHERE ELSE (the day ledger or another intent)? A
99
+ # pointer already on this intent is the owner re-arming mid-delivery and
100
+ # stays idempotent. True means a
101
+ # conversation session. Reads only; rescues to false (fail open).
102
+ def preexisting_pointer?(session, home:)
103
+ path = pointer_path(session, home: home)
104
+ File.exist?(path) && !File.read(path).to_s.strip.empty?
105
+ rescue StandardError
106
+ false
107
+ end
108
+
97
109
  # --- the pointer -------------------------------------------------------------
98
110
 
111
+
99
112
  def pointer_path(session, home:)
100
113
  store = global_store(home)
101
114
  SessionLedger.pointer_path(store, SessionLedger.short_session_id(nil, session))
@@ -125,12 +138,24 @@ module Arm
125
138
  # lock data read and touches nothing.
126
139
  def arm(intent_dir:, session:, mode: "auto", home: Dir.home, harness: nil,
127
140
  agent: nil, model: nil, thread: nil, now: Time.now, runner: Worktree::ShellRunner.new,
128
- host: Socket.gethostname)
141
+ host: Socket.gethostname, allow_inline: false)
129
142
  raise ArgumentError, "mode must be auto or guided" unless %w[auto guided].include?(mode.to_s)
130
143
  dir = File.expand_path(intent_dir)
131
144
  key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
132
145
  h = home_for(dir, home: home)
133
146
 
147
+ # Owner rule 2026-08-31: the main session never delivers an intent inline.
148
+ # A session that already carries a top-level session pointer is a
149
+ # conversation session (SessionStart wrote it at boot); arming there is
150
+ # inline delivery and is refused BEFORE any lock is taken. A dispatched or
151
+ # headless session has no pre-existing pointer and arms freely.
152
+ # --allow-inline is the explicit owner override. Fail open on read errors:
153
+ # a broken pointer file must never block a legitimate delivery.
154
+ if !allow_inline && preexisting_pointer?(key, home: h) &&
155
+ read_pointer(key, home: h).to_s.strip != intent_id_for(dir)
156
+ return { status: :inline_refused, lock: nil, worktree: nil, session: key, pointer: nil }
157
+ end
158
+
134
159
  status, lock = Lock.acquire(dir, session: key, host: host, now: now,
135
160
  harness: harness, agent: agent, model: model,
136
161
  thread: thread, run_mode: mode.to_s)
@@ -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,9 +27,10 @@ class Doctor
26
27
  "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
27
28
  }.freeze
28
29
 
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
+ # 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
32
34
 
33
35
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
34
36
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -508,9 +510,56 @@ class Doctor
508
510
  # agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
509
511
  checks << flat_agents_check(agent_dir, "--claude")
510
512
 
513
+ # compact-instructions block in CLAUDE.md (intent 312)
514
+ checks << claude_compact_instructions_check(agent_dir)
515
+
511
516
  checks
512
517
  end
513
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
+
514
563
  # Unfiltered classification (intent 276, spec Approach table): mode (a)
515
564
  # unowned warns, mode (b) current-but-missing fails, a retired/non-hook
516
565
  # launcher is skipped, a third-party hook stays silent.
@@ -18,6 +18,18 @@ require_relative "session_ledger"
18
18
  module DoctorSessionLedger
19
19
  ORPHAN_TTL_SECONDS = 24 * 60 * 60
20
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
+
21
33
  # Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
22
34
  # else that file's mtime, else the directory's mtime.
23
35
  def heartbeat_age(dir, now)
@@ -37,6 +49,7 @@ module DoctorSessionLedger
37
49
 
38
50
  store_dir = File.join(plastic_home, "store")
39
51
  orphans = orphaned_session_dirs(store_dir, now)
52
+ no_pointer = no_pointer_session_dirs(store_dir, now)
40
53
  shape = day_ledger_shape_problems(store_dir)
41
54
 
42
55
  checks = []
@@ -54,6 +67,20 @@ module DoctorSessionLedger
54
67
  "session is gone: a live session rewrites its heartbeat on every " \
55
68
  "prompt and edit, so only a listed directory may be removed, by hand.")
56
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
57
84
  checks << if shape.empty?
58
85
  check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
59
86
  message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
@@ -86,6 +113,31 @@ module DoctorSessionLedger
86
113
  [] # unreadable .tmp/: nothing to report, never a crash
87
114
  end
88
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
+
89
141
  def day_ledger_shape_problems(store_dir)
90
142
  root = SessionLedger.sessions_root(store_dir)
91
143
  return [] unless File.directory?(root)