@zalom/plastic 2.0.0-alpha.4 → 2.0.0-alpha.6
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/hooks/hooks.json +12 -0
- package/hooks/message-display +71 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +39 -10
- package/scripts/doctor.rb +163 -0
- package/scripts/hook-capture +19 -7
- package/scripts/hook-message-display +65 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/intent-screen +25 -4
- package/scripts/lib/doctor_core.rb +4 -3
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/hook_registry.rb +12 -0
- package/scripts/lib/installer_core.rb +26 -1
- package/scripts/lib/intent_screen.rb +79 -17
- package/scripts/lib/intent_screen_ansi.rb +190 -0
- package/scripts/lib/message_display.rb +371 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +124 -0
- package/skills/intent-continuing/SKILL.md +5 -1
- package/templates/intent-screen.md +0 -5
package/hooks/hooks.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# hooks/message-display (intent 316a, O6, round 3 concurrency fix): the
|
|
3
|
+
# MessageDisplay launcher. Fires on every streamed chunk of every assistant
|
|
4
|
+
# message (D11), so the common case — an ordinary chunk of an ordinary
|
|
5
|
+
# message — must decide with shell builtins alone and fork nothing. Only a
|
|
6
|
+
# candidate message hands off to Ruby (scripts/hook-message-display), which
|
|
7
|
+
# is the one place allowed to do real work.
|
|
8
|
+
#
|
|
9
|
+
# No command substitution, no backticks, no sed/jq/cat: case, [, parameter
|
|
10
|
+
# expansion and printf are all builtins. Deliberately does NOT copy hooks/
|
|
11
|
+
# capture's SCRIPT_DIR-via-subshell pattern (cd into dirname of $0, inside a
|
|
12
|
+
# command substitution, then pwd) — that forks a subshell on every single
|
|
13
|
+
# invocation, which is exactly the cost this hook cannot carry.
|
|
14
|
+
#
|
|
15
|
+
# A live run under a real pty found Claude Code fires these chunk processes
|
|
16
|
+
# CONCURRENTLY: a chunk with index > 0 can arrive, and be judged here,
|
|
17
|
+
# before chunk 0 ever runs. The OLD hand-off test — "does a buffer already
|
|
18
|
+
# exist for this message" — answered no in that race and silently dropped
|
|
19
|
+
# the chunk before Ruby ever saw it, no matter what MessageDisplay's own
|
|
20
|
+
# (correct) polling logic would have done. So a later chunk is now also
|
|
21
|
+
# handed off when its OWN delta looks like it could be part of a screen
|
|
22
|
+
# (leading "|" or "**Steps**", or blank — the same cheap test Ruby itself
|
|
23
|
+
# uses to decide whether a wait is worth paying for), and the final chunk is
|
|
24
|
+
# ALWAYS handed off, whatever it looks like, since it is the one that must
|
|
25
|
+
# not race. Ruby is the one place that actually waits (bounded, injectable
|
|
26
|
+
# for tests); this script only ever decides once, fast, and never sleeps.
|
|
27
|
+
IFS= read -r -d '' INPUT # returns 1 at EOF: do NOT set -e
|
|
28
|
+
case $INPUT in *'"message_id"'*) ;; *) exit 0 ;; esac
|
|
29
|
+
rest=${INPUT#*\"message_id\"}; rest=${rest#*\"}; mid=${rest%%\"*}
|
|
30
|
+
rest=${INPUT#*\"session_id\"}; rest=${rest#*\"}; sid=${rest%%\"*}
|
|
31
|
+
|
|
32
|
+
SCRIPT_DIR=${0%/*}
|
|
33
|
+
TMP_ROOT=${PLASTIC_TMP:-${TMPDIR:-/tmp}}
|
|
34
|
+
export PLASTIC_TMP="$TMP_ROOT"
|
|
35
|
+
MSGDIR="$TMP_ROOT/plastic-message-display/$sid/$mid"
|
|
36
|
+
|
|
37
|
+
is_index_zero=0
|
|
38
|
+
case $INPUT in *'"index":0'*|*'"index": 0'*) is_index_zero=1 ;; esac
|
|
39
|
+
|
|
40
|
+
is_final=0
|
|
41
|
+
case $INPUT in *'"final":true'*|*'"final": true'*) is_final=1 ;; esac
|
|
42
|
+
|
|
43
|
+
handoff=0
|
|
44
|
+
if [ "$is_index_zero" = 1 ]; then
|
|
45
|
+
# Chunk 0 decides synchronously; a bare "#" first delta, in either JSON
|
|
46
|
+
# spacing, is the only shape that can possibly open a screen. Must be a
|
|
47
|
+
# single "#", not "##" — a real screen's own first delta can be as short
|
|
48
|
+
# as "## " — a "##" glob would filter out exactly the message this hook
|
|
49
|
+
# exists to recognize.
|
|
50
|
+
case $INPUT in
|
|
51
|
+
*'"delta":"#'*|*'"delta": "#'*) handoff=1 ;;
|
|
52
|
+
esac
|
|
53
|
+
else
|
|
54
|
+
# A later chunk: hand off when this message's directory already exists
|
|
55
|
+
# (chunk 0 already left a decision or a chunk file), when this chunk is
|
|
56
|
+
# final (it must always be checked, whatever it looks like), or when its
|
|
57
|
+
# own delta is shaped like part of a screen — a Markdown table row, the
|
|
58
|
+
# "**Steps**" heading, or a blank line, in either JSON spacing.
|
|
59
|
+
[ -d "$MSGDIR" ] && handoff=1
|
|
60
|
+
[ "$is_final" = 1 ] && handoff=1
|
|
61
|
+
case $INPUT in
|
|
62
|
+
*'"delta":"|'*|*'"delta": "|'*) handoff=1 ;;
|
|
63
|
+
*'"delta":"**Steps**'*|*'"delta": "**Steps**'*) handoff=1 ;;
|
|
64
|
+
*'"delta":""'*|*'"delta": ""'*) handoff=1 ;;
|
|
65
|
+
*'"delta":"\n"'*|*'"delta": "\n"'*) handoff=1 ;;
|
|
66
|
+
esac
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
[ "$handoff" = 1 ] || exit 0
|
|
70
|
+
|
|
71
|
+
printf '%s' "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-message-display"
|
package/package.json
CHANGED
package/scripts/dashboard.rb
CHANGED
|
@@ -56,13 +56,26 @@ rescue StandardError
|
|
|
56
56
|
nil
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
#
|
|
59
|
+
# The link form's id: "- [id](path)...". The class excludes "[" (as well as
|
|
60
|
+
# "]" and whitespace) so this can never match a wikilink line's OWN opening
|
|
61
|
+
# bracket and key it as "[71" (row F, spec 315b): "- [[71]] ..." fails this
|
|
62
|
+
# pattern outright (the character right after "- [" is itself "[", which the
|
|
63
|
+
# class disallows), leaving it to WIKILINK_ID_RE below.
|
|
64
|
+
LINK_ID_RE = /^- \[([^\[\]\s]+)/
|
|
65
|
+
|
|
66
|
+
# The wikilink form's id: "- [[id]] name (em dash) description (date; ...)",
|
|
67
|
+
# the shape mihradesign's INDEX.md and others use. Bare id, no brackets.
|
|
68
|
+
WIKILINK_ID_RE = /^- \[\[([^\[\]]+)\]\]/
|
|
69
|
+
|
|
70
|
+
# Returns the set of intent ids listed under a given INDEX.md section, link
|
|
71
|
+
# form and wikilink form alike (row F5: Active, Abandoned, Completed, and a
|
|
72
|
+
# store's Future section can all mix both forms).
|
|
60
73
|
def index_section_ids(index_path, header)
|
|
61
74
|
return [] unless File.exist?(index_path)
|
|
62
75
|
body = File.read(index_path)
|
|
63
76
|
seg = body[/^#{Regexp.escape(header)}\s*\n(.*?)(?=^## |\z)/m, 1]
|
|
64
77
|
return [] unless seg
|
|
65
|
-
seg.scan(
|
|
78
|
+
seg.scan(LINK_ID_RE).flatten + seg.scan(WIKILINK_ID_RE).flatten
|
|
66
79
|
end
|
|
67
80
|
|
|
68
81
|
# Bug found at intent 202's gate review: the date used to be anchored to the END of the
|
|
@@ -77,21 +90,37 @@ end
|
|
|
77
90
|
# (end-intent's own Bridge.index_entry_match accepts either on read). Because regex
|
|
78
91
|
# alternation is leftmost-first, this only ever matches the date immediately after the
|
|
79
92
|
# link. It never continues scanning into the note prose, so a second date mentioned
|
|
80
|
-
# later in a note's free text cannot be mistaken for the completion date.
|
|
81
|
-
|
|
93
|
+
# later in a note's free text cannot be mistaken for the completion date. The id class
|
|
94
|
+
# excludes "[" (row F) so this can never fire on a wikilink line's own opening bracket.
|
|
95
|
+
COMPLETION_DATE_RE = /^- \[([^\[\]\s]+).*?\)\s*[\u2014-]\s*(\d{4}-\d{2}-\d{2})\b/
|
|
96
|
+
|
|
97
|
+
# The wikilink form's completion date: "- [[id]] name (em dash) description
|
|
98
|
+
# (date; other notes)". Real shape (mihradesign's INDEX.md): the date is the
|
|
99
|
+
# first thing inside the entry's trailing parenthetical. A SEPARATE regex
|
|
100
|
+
# from COMPLETION_DATE_RE (row F review finding), not one four-group
|
|
101
|
+
# alternation: a four-group `scan` breaks `String#scan(...).to_h`, and a bare
|
|
102
|
+
# `\(` alternative on the link form would harvest a date out of note prose on
|
|
103
|
+
# an entry with no canonical date of its own.
|
|
104
|
+
WIKILINK_COMPLETION_DATE_RE = /^- \[\[([^\[\]]+)\]\].*?\((\d{4}-\d{2}-\d{2})\b/
|
|
82
105
|
|
|
83
106
|
# Map of intent id -> completion date string, parsed from the "## Completed" section
|
|
84
|
-
# (lines like "- [12 (em dash) title](link) (em dash) 2026-06-10 optional note text"
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
107
|
+
# (lines like "- [12 (em dash) title](link) (em dash) 2026-06-10 optional note text",
|
|
108
|
+
# or the wikilink form "- [[12]] title (em dash) description (2026-06-10; notes)").
|
|
109
|
+
# Deterministic, content-derived. Two regexes scanned separately and merged into one
|
|
110
|
+
# hash (row F), so each form's own matching rules stay independent: the wikilink
|
|
111
|
+
# alternative can never steal a date out of a link-form entry's note prose, because it
|
|
112
|
+
# never even looks at a link-form line (its "- [[" anchor cannot match a line whose
|
|
113
|
+
# second character is not itself "["). Observability: a populated "## Completed"
|
|
114
|
+
# section that yields not one single dated entry is a parser regression, not a
|
|
115
|
+
# legitimately empty result, so it is surfaced with a stderr warning rather than
|
|
116
|
+
# rotting invisibly (the same silent-failure class intent 202's gate review caught this
|
|
117
|
+
# file already committing).
|
|
89
118
|
def completion_dates(index_path)
|
|
90
119
|
return {} unless File.exist?(index_path)
|
|
91
120
|
body = File.read(index_path)
|
|
92
121
|
seg = body[/^## Completed\s*\n(.*?)(?=^## |\z)/m, 1] || ""
|
|
93
122
|
entry_count = seg.scan(/^- \[/).size
|
|
94
|
-
dates = seg.scan(COMPLETION_DATE_RE).to_h
|
|
123
|
+
dates = seg.scan(COMPLETION_DATE_RE).to_h.merge(seg.scan(WIKILINK_COMPLETION_DATE_RE).to_h)
|
|
95
124
|
if entry_count.positive? && dates.empty?
|
|
96
125
|
warn "dashboard: completion_dates parsed 0/#{entry_count} dates from the " \
|
|
97
126
|
"\"## Completed\" section of #{index_path}; treat this as a parser regression, " \
|
package/scripts/doctor.rb
CHANGED
|
@@ -288,6 +288,167 @@ class Doctor
|
|
|
288
288
|
# content scanning. check_global_store (above) is store/full-scope and includes
|
|
289
289
|
# orphaned_intents / ghost_references, both explicitly forbidden at core by D1.
|
|
290
290
|
|
|
291
|
+
# Row E (spec D8, spec 315b): #check_core_files's own `version_match` only ever
|
|
292
|
+
# compares the SELECTED harness's `<dir>/plastic/VERSION` against the global
|
|
293
|
+
# VERSION, so a second installed harness left behind on an old version is never
|
|
294
|
+
# version-checked at all -- exactly how ten stale 1.14.1 Codex registrations
|
|
295
|
+
# survived a `pass`. This runs the same comparison for EVERY installed harness
|
|
296
|
+
# (agent_dir on disk), named `version_match_<key>` so the existing `version_match`
|
|
297
|
+
# name and position stay reserved for the selected harness (pinned tests:
|
|
298
|
+
# test/doctor_test.rb:1846,1861,1873). Full tier only (`run_checks`); never
|
|
299
|
+
# `run_core_checks`, which is `binary: true` and runs on the session-start boot
|
|
300
|
+
# path, where a second-harness warn would become a boot-time error for every user.
|
|
301
|
+
def check_harness_versions
|
|
302
|
+
global_version = read_version
|
|
303
|
+
return [] unless global_version
|
|
304
|
+
|
|
305
|
+
agents.filter_map do |key, config|
|
|
306
|
+
next unless config.is_a?(Hash) && File.directory?(config[:dir].to_s)
|
|
307
|
+
|
|
308
|
+
agent_version_path = File.join(config[:dir], "plastic", "VERSION")
|
|
309
|
+
name = "version_match_#{key}"
|
|
310
|
+
|
|
311
|
+
if !File.exist?(agent_version_path)
|
|
312
|
+
check(
|
|
313
|
+
category: "core_files", name: name, status: "warn",
|
|
314
|
+
message: "#{config[:name]}'s agent-side VERSION file not found at #{tilde(agent_version_path)}",
|
|
315
|
+
fixable: true,
|
|
316
|
+
fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
|
|
317
|
+
)
|
|
318
|
+
else
|
|
319
|
+
agent_version = File.read(agent_version_path).strip
|
|
320
|
+
if global_version == agent_version
|
|
321
|
+
check(
|
|
322
|
+
category: "core_files", name: name, status: "pass",
|
|
323
|
+
message: "Global VERSION (#{global_version}) matches #{config[:name]}'s agent-side VERSION"
|
|
324
|
+
)
|
|
325
|
+
else
|
|
326
|
+
check(
|
|
327
|
+
category: "core_files", name: name, status: "warn",
|
|
328
|
+
message: "Version mismatch for #{config[:name]}: global=#{global_version}, agent=#{agent_version}",
|
|
329
|
+
details: [
|
|
330
|
+
"#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
|
|
331
|
+
"#{tilde(agent_version_path)}: #{agent_version}",
|
|
332
|
+
],
|
|
333
|
+
fixable: true,
|
|
334
|
+
fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
|
|
335
|
+
)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The Codex hook NAME expected per event, keyed off HookRegistry's own
|
|
342
|
+
# constants rather than a full built command string: naming it, not the
|
|
343
|
+
# literal dispatcher path, is what #check_codex_stale_registrations needs,
|
|
344
|
+
# so this never depends on plastic_home resolving to the same install the
|
|
345
|
+
# live hooks.json's commands were written under.
|
|
346
|
+
def codex_expected_names_by_event
|
|
347
|
+
names = {}
|
|
348
|
+
post_order = HookRegistry.events["PostToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
349
|
+
names["PostToolUse"] = HookRegistry::CODEX_POST_HOOKS & post_order
|
|
350
|
+
|
|
351
|
+
HookRegistry::CODEX_LIVE_STATE_EVENTS.each do |event|
|
|
352
|
+
names[event] = HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
end_order = HookRegistry.events["SessionEnd"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
356
|
+
names["SessionEnd"] = HookRegistry::CODEX_SESSION_END_HOOKS & end_order
|
|
357
|
+
names
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Row E (spec D8, spec 315b): #codex_hooks_registered_check computes
|
|
361
|
+
# `want - got` and iterates `expected.each`, so it can see a MISSING registration
|
|
362
|
+
# but never an EXTRA one, and it can never even visit an event `expected` does not
|
|
363
|
+
# list at all (a retired event such as PreToolUse, which a stale ~/.codex/hooks.json
|
|
364
|
+
# can still carry). This scans the UNION of expected and live event keys instead, so
|
|
365
|
+
# a stale registration parked in a retired event is visible. Compares hook NAMES
|
|
366
|
+
# (see #codex_expected_names_by_event), not full command strings: a full-command
|
|
367
|
+
# comparison would embed this Doctor instance's own `plastic_home` in the expected
|
|
368
|
+
# dispatcher path, which is only guaranteed to match the live hooks.json's own
|
|
369
|
+
# dispatcher path in a real, single install, never a test that fakes one without the
|
|
370
|
+
# other. Full tier only (`run_checks`); never `run_core_checks` (D8).
|
|
371
|
+
#
|
|
372
|
+
# Post-execution review item 5: name-only comparison alone is blind to two real
|
|
373
|
+
# cases, both closed here without reintroducing any plastic_home coupling: (a) the
|
|
374
|
+
# SAME (event, name) pair registered more than once (each copy's name is
|
|
375
|
+
# individually expected, so a "not in the expected set" scan never sees the
|
|
376
|
+
# duplicate), and (b) a correctly-named entry whose dispatcher path is an OLD
|
|
377
|
+
# install, different from the path every OTHER Plastic entry in this same file
|
|
378
|
+
# actually uses -- the "correct" path here is whichever path the file's own
|
|
379
|
+
# majority of entries already agrees on, never a path this Doctor instance derives
|
|
380
|
+
# from its own plastic_home.
|
|
381
|
+
def check_codex_stale_registrations
|
|
382
|
+
config = agents["codex"]
|
|
383
|
+
return [] unless config.is_a?(Hash) && File.directory?(config[:dir].to_s)
|
|
384
|
+
|
|
385
|
+
home_dir = config[:home_dir] || config[:dir]
|
|
386
|
+
hooks_json = File.join(home_dir, "hooks.json")
|
|
387
|
+
data = read_json_safe(hooks_json)
|
|
388
|
+
return [] if data.nil? || !data.is_a?(Hash)
|
|
389
|
+
|
|
390
|
+
expected_names = codex_expected_names_by_event
|
|
391
|
+
live = data["hooks"].is_a?(Hash) ? data["hooks"] : {}
|
|
392
|
+
|
|
393
|
+
# One pass over every live Plastic entry (event, hook name, and the literal
|
|
394
|
+
# dispatcher-path token it actually invokes), shared by all three checks below.
|
|
395
|
+
entries = []
|
|
396
|
+
(expected_names.keys | live.keys).each do |event|
|
|
397
|
+
Array(live[event]).each do |group|
|
|
398
|
+
Array(group["hooks"]).each do |h|
|
|
399
|
+
cmd = h["command"]
|
|
400
|
+
next unless HookRegistry.codex_purge_command?(cmd)
|
|
401
|
+
|
|
402
|
+
dispatcher_path = cmd.to_s.split(/\s+/).reject(&:empty?).first.to_s.delete("\"'")
|
|
403
|
+
name = HookRegistry.command_basenames(cmd).last
|
|
404
|
+
entries << { event: event, name: name, dispatcher_path: dispatcher_path, cmd: cmd }
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
stale = []
|
|
410
|
+
|
|
411
|
+
# Not in the expected name set for this event at all (the original check).
|
|
412
|
+
entries.each do |e|
|
|
413
|
+
want_names = Array(expected_names[e[:event]])
|
|
414
|
+
stale << "#{e[:event]}: #{e[:cmd]}" unless want_names.include?(e[:name])
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# (a) the same (event, name) pair registered more than once.
|
|
418
|
+
entries.group_by { |e| [e[:event], e[:name]] }.each_value do |group|
|
|
419
|
+
next if group.size <= 1
|
|
420
|
+
|
|
421
|
+
group.each { |e| stale << "#{e[:event]}: #{e[:cmd]} (duplicate registration of #{e[:name]})" }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# (b) a dispatcher path that differs from the path every other Plastic entry in
|
|
425
|
+
# this same file actually uses.
|
|
426
|
+
unless entries.empty?
|
|
427
|
+
majority_path = entries.map { |e| e[:dispatcher_path] }.tally.max_by { |(_path, count)| count }&.first
|
|
428
|
+
entries.each do |e|
|
|
429
|
+
next if e[:dispatcher_path] == majority_path
|
|
430
|
+
|
|
431
|
+
stale << "#{e[:event]}: #{e[:cmd]} (dispatcher path #{e[:dispatcher_path]} differs from " \
|
|
432
|
+
"this file's other Plastic entries at #{majority_path})"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
stale.uniq!
|
|
437
|
+
|
|
438
|
+
if stale.empty?
|
|
439
|
+
[check(
|
|
440
|
+
category: "agent_registration", name: "codex_stale_registrations", status: "pass",
|
|
441
|
+
message: "No stale Plastic Codex registrations found outside the expected hook set"
|
|
442
|
+
)]
|
|
443
|
+
else
|
|
444
|
+
[check(
|
|
445
|
+
category: "agent_registration", name: "codex_stale_registrations", status: "warn",
|
|
446
|
+
message: "#{stale.size} stale Codex registration(s) found outside the expected hook set",
|
|
447
|
+
details: stale, fixable: true,
|
|
448
|
+
fix_hint: "Re-run the Plastic installer with --codex --reinstall to purge stale entries"
|
|
449
|
+
)]
|
|
450
|
+
end
|
|
451
|
+
end
|
|
291
452
|
# --- Check category 2: Conventions ---
|
|
292
453
|
|
|
293
454
|
# When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
|
|
@@ -2235,6 +2396,8 @@ end
|
|
|
2235
2396
|
all_checks += check_conventions(scopes: ["global"])
|
|
2236
2397
|
all_checks += check_agent_registration(agent_key)
|
|
2237
2398
|
all_checks += check_core_files(agent_key)
|
|
2399
|
+
all_checks += check_harness_versions
|
|
2400
|
+
all_checks += check_codex_stale_registrations
|
|
2238
2401
|
all_checks += check_deprecations
|
|
2239
2402
|
all_checks += check_config_asks(agent_key)
|
|
2240
2403
|
all_checks += check_qmd
|
package/scripts/hook-capture
CHANGED
|
@@ -129,11 +129,20 @@ def truncate(text, max)
|
|
|
129
129
|
"#{text[0, max - 3]}..."
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
-
# --- (a) tmp root + heartbeat,
|
|
132
|
+
# --- (a) tmp root + heartbeat, guarded on the pointer (spec D9) ------------
|
|
133
|
+
# Session start writes the `current` pointer; a capture with no pointer yet
|
|
134
|
+
# means session start never ran for this session at all (a `-p` print
|
|
135
|
+
# session, a resumed background job, an unregistered SessionStart hook), so
|
|
136
|
+
# neither the per-session dir nor its heartbeat is created. UserPromptSubmit
|
|
137
|
+
# fires before PostToolUse, so capture is the FIRST creator of every orphan
|
|
138
|
+
# `.tmp/<sid>/` this guard prevents; hook-record carries the same guard for
|
|
139
|
+
# its own (later) heartbeat write.
|
|
133
140
|
begin
|
|
134
|
-
SessionLedger.
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
if File.exist?(SessionLedger.pointer_path(store, sid))
|
|
142
|
+
SessionLedger.ensure_tmp_root(store)
|
|
143
|
+
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
|
|
144
|
+
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
145
|
+
end
|
|
137
146
|
rescue StandardError
|
|
138
147
|
nil
|
|
139
148
|
end
|
|
@@ -151,11 +160,14 @@ rescue StandardError
|
|
|
151
160
|
end
|
|
152
161
|
|
|
153
162
|
# --- (c) pending checklist line -------------------------------------------
|
|
163
|
+
# Gated on SessionLedger.capture_worthy?(prompt) (spec D2), a pure predicate
|
|
164
|
+
# over the RAW prompt so it sees the true first character and multi-line
|
|
165
|
+
# shape; the line text itself is still the sanitized, truncated copy.
|
|
154
166
|
unless skip_day_ledger
|
|
155
167
|
begin
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
168
|
+
if SessionLedger.capture_worthy?(prompt)
|
|
169
|
+
project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
|
|
170
|
+
clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
|
|
159
171
|
SessionLedger.open_day(store: store, day: today, templates: templates, author: sid)
|
|
160
172
|
line = SessionLedger.checklist_line(:pending, sid, project, clean)
|
|
161
173
|
SessionLedger.append_line(SessionLedger.checklist_path(store, today), line,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# hook-message-display (intent 316a, O4): the MessageDisplay hook CLI. Reads
|
|
6
|
+
# the harness's per-chunk JSON payload from stdin, hands it to MessageDisplay
|
|
7
|
+
# (the pure handler class), and prints the hookSpecificOutput envelope when a
|
|
8
|
+
# String comes back. Always exits 0, whatever happens — a raised exception
|
|
9
|
+
# here must never surface as a non-zero exit or stray stderr on an ordinary
|
|
10
|
+
# chunk of an ordinary message (matrix 31).
|
|
11
|
+
#
|
|
12
|
+
# This script is the one place in the O4 stack allowed to read ENV, the
|
|
13
|
+
# clock, or Dir.tmpdir: MessageDisplay itself takes tmp_root/plastic_home/
|
|
14
|
+
# color/now as constructor arguments and touches none of them directly.
|
|
15
|
+
|
|
16
|
+
require "json"
|
|
17
|
+
require "time"
|
|
18
|
+
require "tmpdir"
|
|
19
|
+
require "yaml"
|
|
20
|
+
require_relative "lib/message_display"
|
|
21
|
+
|
|
22
|
+
def color_enabled?(plastic_home)
|
|
23
|
+
return false unless ENV["NO_COLOR"].to_s.empty?
|
|
24
|
+
|
|
25
|
+
cfg_path = File.join(plastic_home, "config.yml")
|
|
26
|
+
return true unless File.exist?(cfg_path)
|
|
27
|
+
|
|
28
|
+
cfg = YAML.safe_load(File.read(cfg_path))
|
|
29
|
+
display = cfg.is_a?(Hash) ? cfg["display"] : nil
|
|
30
|
+
return true unless display.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
display.fetch("ansi_screen", true) != false
|
|
33
|
+
rescue StandardError
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
raw = $stdin.tty? ? "" : $stdin.read
|
|
39
|
+
payload = raw.to_s.strip.empty? ? nil : JSON.parse(raw)
|
|
40
|
+
|
|
41
|
+
if payload.is_a?(Hash)
|
|
42
|
+
tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
|
|
43
|
+
plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
|
|
44
|
+
|
|
45
|
+
handler = MessageDisplay.new(
|
|
46
|
+
tmp_root: tmp_root,
|
|
47
|
+
plastic_home: plastic_home,
|
|
48
|
+
color: color_enabled?(plastic_home),
|
|
49
|
+
now: Time.now,
|
|
50
|
+
)
|
|
51
|
+
result = handler.handle(payload)
|
|
52
|
+
|
|
53
|
+
if result.is_a?(String)
|
|
54
|
+
puts JSON.generate(
|
|
55
|
+
"hookSpecificOutput" => {
|
|
56
|
+
"hookEventName" => "MessageDisplay",
|
|
57
|
+
"displayContent" => result,
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
exit 0
|
package/scripts/hook-record
CHANGED
|
@@ -135,13 +135,21 @@ unless skip_day_ledger
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
# --- (d) heartbeat,
|
|
138
|
+
# --- (d) heartbeat, guarded on the pointer (spec D9) -----------------------
|
|
139
|
+
# Session start writes the `current` pointer; with no pointer at all, session
|
|
140
|
+
# start never ran for this session (a `-p` print session, a resumed
|
|
141
|
+
# background job, an unregistered SessionStart hook), so neither the
|
|
142
|
+
# per-session dir nor its heartbeat is created here -- guarding only the
|
|
143
|
+
# write would still leave an empty directory that heartbeat_age's mtime
|
|
144
|
+
# fallback reports as an orphan just the same.
|
|
139
145
|
begin
|
|
140
146
|
store ||= File.join(plastic_home, "store")
|
|
141
147
|
sid ||= SessionLedger.short_session_id(nil, session_id)
|
|
142
|
-
SessionLedger.
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
if File.exist?(SessionLedger.pointer_path(store, sid))
|
|
149
|
+
SessionLedger.ensure_tmp_root(store)
|
|
150
|
+
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
|
|
151
|
+
File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
|
|
152
|
+
end
|
|
145
153
|
rescue StandardError
|
|
146
154
|
nil
|
|
147
155
|
end
|
|
@@ -18,6 +18,24 @@ require_relative "lib/day_summary"
|
|
|
18
18
|
index_path, plastic_home, mode, plugin_root = ARGV
|
|
19
19
|
exit 0 unless index_path && plastic_home && mode
|
|
20
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
|
+
|
|
21
39
|
# Plastic home and the store are two different paths (intent 231). The shim passes
|
|
22
40
|
# home (~/.plastic) as argument 2; the store lives one level below it. Compose the
|
|
23
41
|
# store exactly once here, so no later line re-derives it and no path can gain a
|
|
@@ -361,7 +379,11 @@ begin
|
|
|
361
379
|
author = "session" if author.empty?
|
|
362
380
|
SessionLedger.open_day(store: store_dir, day: day, templates: templates, author: author)
|
|
363
381
|
|
|
364
|
-
session =
|
|
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
|
|
365
387
|
sid = SessionLedger.short_session_id(nil, session)
|
|
366
388
|
SessionLedger.ensure_tmp_root(store_dir)
|
|
367
389
|
FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store_dir, sid))
|
package/scripts/intent-screen
CHANGED
|
@@ -8,18 +8,27 @@
|
|
|
8
8
|
# the close; it never edits the numbers.
|
|
9
9
|
#
|
|
10
10
|
# Usage:
|
|
11
|
-
# intent-screen <intent_dir> [--template <path>]
|
|
11
|
+
# intent-screen <intent_dir> [--template <path>] [--ansi]
|
|
12
12
|
#
|
|
13
13
|
# The store root is the directory two levels above the intent (<root>/store/<id--slug>);
|
|
14
14
|
# the template defaults to templates/intent-screen.md next to this script's dir,
|
|
15
15
|
# in-repo (<repo>/scripts -> <repo>/templates) and installed (~/.plastic/scripts ->
|
|
16
16
|
# ~/.plastic/templates) alike.
|
|
17
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
|
+
#
|
|
18
26
|
# Exit codes:
|
|
19
27
|
# 0 - the screen is on stdout
|
|
20
28
|
# 2 - usage error, or the path is not an intent directory (one line on stderr)
|
|
21
29
|
|
|
22
30
|
require_relative "lib/intent_screen"
|
|
31
|
+
require_relative "lib/intent_screen_ansi"
|
|
23
32
|
|
|
24
33
|
def usage_abort(message)
|
|
25
34
|
warn "intent-screen: #{message}"
|
|
@@ -28,18 +37,21 @@ end
|
|
|
28
37
|
|
|
29
38
|
args = ARGV.dup
|
|
30
39
|
template_path = nil
|
|
40
|
+
ansi = false
|
|
31
41
|
positional = []
|
|
32
42
|
while (arg = args.shift)
|
|
33
43
|
case arg
|
|
34
44
|
when "--template"
|
|
35
45
|
template_path = args.shift or usage_abort("--template needs a path")
|
|
46
|
+
when "--ansi"
|
|
47
|
+
ansi = true
|
|
36
48
|
else
|
|
37
49
|
usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
|
|
38
50
|
positional << arg
|
|
39
51
|
end
|
|
40
52
|
end
|
|
41
53
|
|
|
42
|
-
usage_abort("usage: intent-screen <intent_dir> [--template <path>]") unless positional.length == 1
|
|
54
|
+
usage_abort("usage: intent-screen <intent_dir> [--template <path>] [--ansi]") unless positional.length == 1
|
|
43
55
|
intent_dir = File.expand_path(positional.first)
|
|
44
56
|
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
45
57
|
|
|
@@ -47,6 +59,15 @@ store_root = File.expand_path("../..", intent_dir)
|
|
|
47
59
|
template_path ||= File.expand_path("../templates/intent-screen.md", __dir__)
|
|
48
60
|
usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
plain = -> { IntentScreen.render(intent_dir: intent_dir, store_root: store_root, template: File.read(template_path)) }
|
|
63
|
+
|
|
64
|
+
degrade_to_plain = ENV.key?("NO_COLOR") || !$stdout.tty?
|
|
65
|
+
|
|
66
|
+
$stdout.write(
|
|
67
|
+
if ansi && !degrade_to_plain
|
|
68
|
+
IntentScreenAnsi.render(intent_dir: intent_dir, store_root: store_root, color: true)
|
|
69
|
+
else
|
|
70
|
+
plain.call
|
|
71
|
+
end
|
|
72
|
+
)
|
|
52
73
|
exit 0
|
|
@@ -27,9 +27,10 @@ class Doctor
|
|
|
27
27
|
"hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
|
|
28
28
|
}.freeze
|
|
29
29
|
|
|
30
|
-
# The Claude events hooks_registered expects in settings.json: the
|
|
31
|
-
# cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301
|
|
32
|
-
|
|
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
|
|
33
34
|
|
|
34
35
|
# Launchers the installer places in the agent's hooks dir that are NOT hooks
|
|
35
36
|
# (intent 204): plastic-statusline is the settings["statusLine"] command, wired
|