@zalom/plastic 2.0.0-alpha.1 → 2.0.0-alpha.3
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.
- package/bin/lib/context_budget.rb +453 -0
- package/bin/plastic-bench +78 -0
- package/hooks/savepoint +5 -5
- package/package.json +1 -1
- package/scripts/append-ledger +16 -3
- package/scripts/day-summary +53 -0
- package/scripts/hook-capture +2 -1
- package/scripts/hook-close +3 -1
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +11 -0
- package/scripts/lib/compact_instructions.rb +56 -0
- package/scripts/lib/day_summary.rb +206 -0
- package/scripts/lib/doctor_core.rb +48 -0
- package/scripts/lib/handoff.rb +184 -0
- package/scripts/lib/installer_core.rb +85 -10
- package/scripts/lib/session_close.rb +22 -2
- package/scripts/read-config +3 -0
- package/scripts/rollback.rb +6 -0
- package/scripts/write-handoff +60 -0
- package/skills/intent-continuing/SKILL.md +3 -2
- package/templates/config.yml +5 -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,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
|