@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 CHANGED
@@ -64,6 +64,18 @@
64
64
  }
65
65
  ]
66
66
  }
67
+ ],
68
+ "MessageDisplay": [
69
+ {
70
+ "matcher": "",
71
+ "hooks": [
72
+ {
73
+ "type": "command",
74
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" message-display",
75
+ "statusMessage": ""
76
+ }
77
+ ]
78
+ }
67
79
  ]
68
80
  }
69
81
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.4",
3
+ "version": "2.0.0-alpha.6",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,13 +56,26 @@ rescue StandardError
56
56
  nil
57
57
  end
58
58
 
59
- # Returns the set of intent ids listed under a given INDEX.md section.
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(/^- \[([^\]\s]+)/).flatten
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
- COMPLETION_DATE_RE = /^- \[([^\]\s]+).*?\)\s*[\u2014-]\s*(\d{4}-\d{2}-\d{2})\b/
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
- # Deterministic, content-derived. Observability: a populated "## Completed" section that
86
- # yields not one single dated entry is a parser regression, not a legitimately empty
87
- # result, so it is surfaced with a stderr warning rather than rotting invisibly (the same
88
- # silent-failure class intent 202's gate review caught this file already committing).
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
@@ -129,11 +129,20 @@ def truncate(text, max)
129
129
  "#{text[0, max - 3]}..."
130
130
  end
131
131
 
132
- # --- (a) tmp root + heartbeat, unconditional -------------------------------
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.ensure_tmp_root(store)
135
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
136
- File.write(SessionLedger.heartbeat_path(store, sid), "#{Time.now.utc.iso8601}\n")
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
- project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
157
- clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
158
- if clean.length >= 10
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
@@ -135,13 +135,21 @@ unless skip_day_ledger
135
135
  end
136
136
  end
137
137
 
138
- # --- (d) heartbeat, always -------------------------------------------------
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.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")
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 = 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
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))
@@ -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
- $stdout.write IntentScreen.render(intent_dir: intent_dir, store_root: store_root,
51
- template: File.read(template_path))
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 five-event map of
31
- # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301).
32
- 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
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