@zalom/plastic 2.0.0-alpha.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.
@@ -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,6 +13,7 @@ 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
@@ -381,6 +382,16 @@ begin
381
382
  end
382
383
  end
383
384
  parts << "PLASTIC: day ledger #{day} joined (#{open_count} open items, #{pending_count} pending)"
385
+
386
+ # The day summary (intent 311, spec D8): open items, the last five done,
387
+ # live auto intents, other active sessions. Never the raw ledger. A
388
+ # failure here leaves the joined line alone.
389
+ begin
390
+ summary = DaySummary.build(store: store_dir, day: day, session: sid, home: plastic_home, now: Time.now)
391
+ parts << summary unless summary.empty?
392
+ rescue StandardError
393
+ nil
394
+ end
384
395
  rescue StandardError
385
396
  nil
386
397
  end
@@ -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")
@@ -508,9 +509,56 @@ class Doctor
508
509
  # agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
509
510
  checks << flat_agents_check(agent_dir, "--claude")
510
511
 
512
+ # compact-instructions block in CLAUDE.md (intent 312)
513
+ checks << claude_compact_instructions_check(agent_dir)
514
+
511
515
  checks
512
516
  end
513
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
+
514
562
  # Unfiltered classification (intent 276, spec Approach table): mode (a)
515
563
  # unowned warns, mode (b) current-but-missing fails, a retired/non-hook
516
564
  # launcher is skipped, a third-party hook stays silent.
@@ -0,0 +1,184 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Handoff (intent 311): one session's hand-off, a pure rendering of its share
5
+ # of a day ledger (checklist.md and savepoint.md), written into the day
6
+ # directory as handoff--<session>.md at every tick, at PreCompact, and at
7
+ # close. Derived and regenerable: every write renders in full, so a lost or
8
+ # stale copy costs nothing. No environment reads; every path is injected.
9
+
10
+ require "fileutils"
11
+ require_relative "session_ledger"
12
+
13
+ module Handoff
14
+ module_function
15
+
16
+ TRIGGERS = %w[tick precompact close].freeze
17
+ BUDGET = 6144
18
+ OPEN_CAP = 20
19
+ DONE_CAP = 10
20
+ RECENT_CAP = 10
21
+ OTHERS_CAP = 10
22
+ # A ledger summary may run to 200 characters; a hand-off line shows the
23
+ # first 80, so the caps above are reachable inside the byte budget.
24
+ SUMMARY_MAX = 80
25
+ OPEN_STATES = %i[open pending].freeze
26
+ # Trimmed first when the budget is exceeded; Open is the last to shrink.
27
+ TRIM_ORDER = %i[others recent done open].freeze
28
+ RESUME = "Say continue; the day summary at boot and this file carry the state."
29
+
30
+ SAVEPOINT_TAIL_RE = /\A\[([^\]]*)\] \[([^\]]*)\] (.*)\z/m
31
+ private_constant :SAVEPOINT_TAIL_RE
32
+
33
+ def path_for(store, day, session)
34
+ File.join(SessionLedger.day_dir(store, day), "handoff--#{session}.md")
35
+ end
36
+
37
+ # The day this session's hand-off belongs to: the pointer's day id when
38
+ # the pointer holds one, else today (no pointer, or a pointer naming an
39
+ # intent), the same fallback SessionClose uses for the drop at close.
40
+ def day_for(store, session, today:)
41
+ path = SessionLedger.pointer_path(store, session)
42
+ return today unless File.exist?(path)
43
+
44
+ value = File.read(path).strip
45
+ SessionLedger.valid_day_id?(value) ? value : today
46
+ end
47
+
48
+ def clip(summary)
49
+ text = summary.to_s
50
+ text.length > SUMMARY_MAX ? "#{text[0, SUMMARY_MAX]}..." : text
51
+ end
52
+
53
+ # --- readers ---------------------------------------------------------------------
54
+
55
+ def read_items(store, day)
56
+ SessionLedger.read_locked(SessionLedger.checklist_path(store, day))
57
+ .each_line.filter_map { |l| SessionLedger.parse_checklist_line(l) }
58
+ end
59
+
60
+ # Parsed savepoint lines, file order: {time:, event:, session:, project:,
61
+ # summary:}. A line that does not follow the ledger grammar is skipped.
62
+ def read_savepoint(store, day)
63
+ SessionLedger.read_locked(SessionLedger.savepoint_path(store, day)).each_line.filter_map do |raw|
64
+ line = raw.chomp.scrub
65
+ next if line.empty?
66
+
67
+ time, event, rest = line.split(/\s{2,}/, 3)
68
+ next unless time && event && rest
69
+
70
+ match = SAVEPOINT_TAIL_RE.match(rest)
71
+ next unless match
72
+
73
+ { time: time, event: event, session: match[1], project: match[2], summary: match[3] }
74
+ end
75
+ end
76
+
77
+ # --- rendering, pure -----------------------------------------------------------
78
+
79
+ def render(store:, day:, session:, trigger:, now: Time.now)
80
+ raise ArgumentError, "unknown trigger: #{trigger.inspect}" unless TRIGGERS.include?(trigger)
81
+
82
+ items = read_items(store, day)
83
+ mine = items.select { |i| i[:session] == session }
84
+ lists = {
85
+ open: mine.select { |i| OPEN_STATES.include?(i[:state]) }.map { |i| item_line(i) },
86
+ done: mine.select { |i| i[:state] == :done }.map { |i| item_line(i) },
87
+ recent: read_savepoint(store, day).select { |e| e[:session] == session }.map { |e| recent_line(e) },
88
+ others: others_lines(items, session),
89
+ }
90
+ hidden = Hash.new(0)
91
+ cap!(lists, hidden, :open, OPEN_CAP)
92
+ cap!(lists, hidden, :done, DONE_CAP)
93
+ cap!(lists, hidden, :recent, RECENT_CAP)
94
+ if lists[:others].size > OTHERS_CAP
95
+ hidden[:others] += lists[:others].size - OTHERS_CAP
96
+ lists[:others] = lists[:others].first(OTHERS_CAP)
97
+ end
98
+
99
+ header = [
100
+ "# Hand-off: session #{session}, #{day}",
101
+ "",
102
+ "Written #{now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')} at #{trigger}",
103
+ "",
104
+ ]
105
+ loop do
106
+ text = compose(header, lists, hidden)
107
+ return text if text.bytesize <= BUDGET
108
+
109
+ key = TRIM_ORDER.find { |k| !lists[k].empty? }
110
+ return text unless key
111
+
112
+ # Open, Done, and Recent keep their newest entries; Others has no order.
113
+ key == :others ? lists[key].pop : lists[key].shift
114
+ hidden[key] += 1
115
+ end
116
+ end
117
+
118
+ # Keeps the newest `cap` lines (the file is chronological) and counts the rest.
119
+ def cap!(lists, hidden, key, cap)
120
+ return unless lists[key].size > cap
121
+
122
+ hidden[key] += lists[key].size - cap
123
+ lists[key] = lists[key].last(cap)
124
+ end
125
+
126
+ def compose(header, lists, hidden)
127
+ sections = [
128
+ section("Open", lists[:open], hidden[:open]),
129
+ section("Done", lists[:done], hidden[:done]),
130
+ section("Recent", lists[:recent], hidden[:recent]),
131
+ section("Others today", lists[:others], hidden[:others]),
132
+ "## Resume\n#{RESUME}\n",
133
+ ]
134
+ (header + sections.compact).join("\n")
135
+ end
136
+
137
+ def section(title, lines, hidden)
138
+ return nil if lines.empty? && hidden.zero?
139
+
140
+ body = lines.dup
141
+ body << "(+#{hidden} more)" if hidden.positive?
142
+ "## #{title}\n#{body.join("\n")}\n"
143
+ end
144
+
145
+ def item_line(item)
146
+ "- [#{item[:project]}] #{clip(item[:summary])}"
147
+ end
148
+
149
+ def recent_line(event)
150
+ "- #{event[:time][11, 5]}Z #{event[:event]} #{clip(event[:summary])}"
151
+ end
152
+
153
+ def others_lines(items, session)
154
+ items.reject { |i| i[:session] == session }
155
+ .group_by { |i| i[:session] }
156
+ .sort
157
+ .map do |sid, theirs|
158
+ open = theirs.count { |i| OPEN_STATES.include?(i[:state]) }
159
+ done = theirs.count { |i| i[:state] == :done }
160
+ "- #{sid}: #{open} open, #{done} done"
161
+ end
162
+ end
163
+
164
+ # --- writing -------------------------------------------------------------------
165
+
166
+ # Opens the day first (a tick after midnight never fails), renders, and
167
+ # writes through a per-process temp file and rename, so a crash leaves no
168
+ # partial hand-off and two writers for one session (a tick overlapping a
169
+ # PreCompact) never share a temp name. Returns the path. With
170
+ # `templates: nil` the day is not scaffolded, only its directory ensured.
171
+ def write(store:, day:, session:, trigger:, templates:, now: Time.now)
172
+ if templates
173
+ SessionLedger.open_day(store: store, day: day, templates: templates, author: session)
174
+ else
175
+ FileUtils.mkdir_p(SessionLedger.day_dir(store, day))
176
+ end
177
+ text = render(store: store, day: day, session: session, trigger: trigger, now: now)
178
+ target = path_for(store, day, session)
179
+ tmp = File.join(File.dirname(target), ".handoff-#{session}-#{Process.pid}-#{Thread.current.object_id}.tmp")
180
+ File.write(tmp, text)
181
+ File.rename(tmp, target)
182
+ target
183
+ end
184
+ end