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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/bin/lib/context_budget.rb +453 -0
  2. package/bin/plastic-bench +78 -0
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +81 -0
  5. package/hooks/savepoint +5 -5
  6. package/package.json +1 -1
  7. package/scripts/agent-report +8 -2
  8. package/scripts/append-ledger +16 -3
  9. package/scripts/dashboard.rb +39 -10
  10. package/scripts/day-summary +53 -0
  11. package/scripts/doctor.rb +163 -0
  12. package/scripts/end-intent +93 -0
  13. package/scripts/hook-capture +21 -8
  14. package/scripts/hook-close +3 -1
  15. package/scripts/hook-message-display +74 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-savepoint +45 -0
  18. package/scripts/hook-session-start +34 -1
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/arm.rb +26 -1
  21. package/scripts/lib/compact_instructions.rb +56 -0
  22. package/scripts/lib/day_summary.rb +211 -0
  23. package/scripts/lib/doctor_core.rb +52 -3
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/handoff.rb +184 -0
  26. package/scripts/lib/hook_registry.rb +14 -0
  27. package/scripts/lib/installer_core.rb +117 -11
  28. package/scripts/lib/intent_screen.rb +309 -0
  29. package/scripts/lib/intent_screen_ansi.rb +262 -0
  30. package/scripts/lib/message_display.rb +290 -0
  31. package/scripts/lib/report_screen.rb +671 -0
  32. package/scripts/lib/savepoint.rb +14 -0
  33. package/scripts/lib/screen_paint.rb +276 -0
  34. package/scripts/lib/session_close.rb +22 -2
  35. package/scripts/lib/session_git.rb +49 -18
  36. package/scripts/lib/session_ledger.rb +124 -0
  37. package/scripts/plastic-lock +8 -1
  38. package/scripts/read-config +3 -0
  39. package/scripts/report-screen +157 -0
  40. package/scripts/rollback.rb +6 -0
  41. package/scripts/savepoint-note +67 -0
  42. package/scripts/spawn-preamble +9 -2
  43. package/scripts/write-handoff +60 -0
  44. package/skills/auto/SKILL.md +15 -8
  45. package/skills/auto/references/human-report-contract.md +59 -53
  46. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  47. package/skills/intent-continuing/SKILL.md +31 -22
  48. package/skills/intent-continuing/references/boarding-matrix.md +5 -5
  49. package/skills/intent-continuing/references/context-management.md +1 -1
  50. package/skills/intent-ending/SKILL.md +8 -2
  51. package/skills/intent-executing/SKILL.md +6 -0
  52. package/templates/config.yml +5 -0
  53. package/templates/intent-screen.md +17 -0
  54. package/templates/outcome.md +14 -1
  55. package/templates/report-state.md +11 -0
@@ -31,6 +31,7 @@
31
31
  # 3 - promote/tick could not take an exclusive lock (filesystem without flock support)
32
32
 
33
33
  require_relative "lib/session_ledger"
34
+ require_relative "lib/handoff"
34
35
 
35
36
  VERBS = %w[pending item promote tick savepoint].freeze
36
37
 
@@ -129,10 +130,21 @@ def run_transition(from, to, event, store, day, session, project, opts)
129
130
  exit 2
130
131
  end
131
132
 
132
- return unless opts[:savepoint]
133
+ if opts[:savepoint]
134
+ line = SessionLedger.savepoint_line(event, session, project, target_summary, now: Time.now)
135
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
136
+ end
133
137
 
134
- line = SessionLedger.savepoint_line(event, session, project, target_summary, now: Time.now)
135
- SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
138
+ return unless to == :done
139
+
140
+ # The hand-off at every tick (intent 311, spec D4): derived and
141
+ # regenerable, so a failure here never fails the tick that was recorded.
142
+ begin
143
+ Handoff.write(store: store, day: day, session: session, trigger: "tick",
144
+ templates: opts[:templates] || default_templates_dir)
145
+ rescue StandardError
146
+ nil
147
+ end
136
148
  end
137
149
 
138
150
  def run_savepoint(store, day, session, project, opts)
@@ -153,6 +165,7 @@ def main(argv)
153
165
  store = opts[:store] ? expand(opts[:store]) : File.join(plastic_home, "store")
154
166
  templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
155
167
  usage_abort("templates dir not found: #{templates}") unless Dir.exist?(templates)
168
+ opts[:templates] = templates
156
169
  cwd = opts[:cwd] ? expand(opts[:cwd]) : Dir.pwd
157
170
 
158
171
  day = opts[:day] || SessionLedger.day_id
@@ -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, " \
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # day-summary (intent 311): print the block SessionStart injects at boot
6
+ # (open items, the last five done, live auto intents, other active
7
+ # sessions). Prints nothing when there is nothing to say.
8
+ #
9
+ # Usage:
10
+ # day-summary [--store <dir>] [--day <YYYYMMDD>] [--session <id>] [--home <dir>]
11
+ #
12
+ # Defaults: the home is $PLASTIC_HOME (~/.plastic), the store is <home>/store,
13
+ # the day is today (local wall clock), the session is $CLAUDE_CODE_SESSION_ID.
14
+ #
15
+ # Exit codes: 0 done; 2 usage error.
16
+
17
+ require_relative "lib/session_ledger"
18
+ require_relative "lib/day_summary"
19
+
20
+ def usage_abort(message)
21
+ warn "day-summary: #{message}"
22
+ exit 2
23
+ end
24
+
25
+ def expand(path)
26
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
27
+ end
28
+
29
+ def parse_args(argv)
30
+ opts = {}
31
+ i = 0
32
+ while i < argv.length
33
+ flag = argv[i]
34
+ usage_abort("unknown argument #{flag.inspect}") unless flag.start_with?("--")
35
+ usage_abort("#{flag} requires a value") if i + 1 >= argv.length
36
+ key = flag.delete_prefix("--").to_sym
37
+ usage_abort("unknown flag #{flag.inspect}") unless %i[store day session home].include?(key)
38
+ opts[key] = argv[i + 1]
39
+ i += 2
40
+ end
41
+ opts
42
+ end
43
+
44
+ opts = parse_args(ARGV)
45
+ home = opts[:home] ? expand(opts[:home]) : expand(ENV.fetch("PLASTIC_HOME", "~/.plastic"))
46
+ store = opts[:store] ? expand(opts[:store]) : File.join(home, "store")
47
+ day = opts[:day] || SessionLedger.day_id
48
+ usage_abort("--day must be eight digits parsing as a real date, got #{day.inspect}") unless SessionLedger.valid_day_id?(day)
49
+ session = SessionLedger.short_session_id(opts[:session], ENV["CLAUDE_CODE_SESSION_ID"])
50
+
51
+ text = DaySummary.build(store: store, day: day, session: session, home: home)
52
+ puts text unless text.empty?
53
+ exit 0
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
@@ -66,6 +66,8 @@
66
66
  # refused before removal; pass --discard-worktree-changes to override
67
67
  # deliberately.
68
68
  # 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
69
+ # 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
70
+ # exception to report-and-proceed; --allow-hollow-report overrides.
69
71
 
70
72
  require "fileutils"
71
73
  require "date"
@@ -77,6 +79,7 @@ require_relative "lib/savepoint"
77
79
  require_relative "lib/lock"
78
80
  require_relative "lib/intent_validator"
79
81
  require_relative "lib/outcome_guard"
82
+ require_relative "lib/report_screen"
80
83
  require_relative "lib/backfill_intent"
81
84
 
82
85
  DISPOSITIONS = %w[delivered abandoned].freeze
@@ -99,6 +102,7 @@ def parse_args(argv)
99
102
  opts = {
100
103
  store: nil, id: nil, disposition: nil, index: nil,
101
104
  outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
105
+ allow_hollow_report: false,
102
106
  session: nil, discard_worktree_changes: false,
103
107
  }
104
108
  i = 0
@@ -115,6 +119,7 @@ def parse_args(argv)
115
119
  when "--discard-worktree-changes" then opts[:discard_worktree_changes] = true
116
120
  when "--no-commit" then opts[:no_commit] = true
117
121
  when "--dry-run" then opts[:dry_run] = true
122
+ when "--allow-hollow-report" then opts[:allow_hollow_report] = true
118
123
  else
119
124
  usage_abort("unknown argument #{arg.inspect}")
120
125
  end
@@ -215,7 +220,75 @@ end
215
220
  # Replace the body of the intent file's `## Outcome` section with `summary`,
216
221
  # leaving every other section and the frontmatter untouched. No-op (returns
217
222
  # content unchanged) when summary is blank or the section is not found.
223
+ # 317a S9 (D7): the self-render gate. Renders the delivered screen's sources on
224
+ # the intent's own record and names what would come out hollow. This is intent
225
+ # 308's ONE deliberate exception to report-and-proceed: a hollow DELIVERED
226
+ # close is refused with exit 7 (--allow-hollow-report overrides), because 317
227
+ # closed exactly this way and the owner read a screen full of "not recorded".
228
+ # Abandoned closes and machine-backfilled outcomes (the backfill marker) are
229
+ # exempt: the reader cannot expect labels the writer never had.
230
+ def hollow_report_reason(intent_dir, disposition)
231
+ return nil unless disposition == "delivered"
232
+
233
+ outcome = File.join(intent_dir, "outcome.md")
234
+ return nil unless File.exist?(outcome)
235
+ text = File.read(outcome)
236
+ return nil if text.include?("<!-- backfilled from the record by end-intent on ")
237
+
238
+ # The gate's evidence bar (A7/B7): it judges only records that CLAIM the
239
+ # modern convention - at least one actions/*.md heading carrying an S-label.
240
+ # A terse legacy close with no labeled matrix stays report-and-proceed.
241
+ heading_labels = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.flat_map do |path|
242
+ ReportScreen.split_by_headings(File.read(path)).filter_map do |heading, _body|
243
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).find { |tok| tok.match?(/\AS\d+\z/) }
244
+ end
245
+ end.uniq
246
+ return nil if heading_labels.empty?
247
+
248
+ rows = ReportScreen.delivered_rows(intent_dir)
249
+ return "outcome.md ## Delivered renders no rows although a labeled action matrix exists" if rows.empty?
250
+
251
+ needs = ReportScreen.section_of(text, "## Needs you").gsub(/<!--.*?-->/m, "").strip
252
+ if !needs.empty? && needs != "None" && ReportScreen.needs_you_rows(intent_dir).empty?
253
+ return "## Needs you has content that would render as None"
254
+ end
255
+
256
+ labels = rows.map { |r| r[:label] }
257
+ if (labels & heading_labels).empty?
258
+ return "delivered row labels (#{labels.first(3).join(', ')}...) match no action-file heading (#{heading_labels.first(3).join(', ')}...)"
259
+ end
260
+ if rows.all? { |r| ReportScreen.proven_by(intent_dir, r[:label]) == ReportScreen::NOT_RECORDED }
261
+ return "every Proven-by cell renders not recorded although labeled action headings exist"
262
+ end
263
+
264
+ nil
265
+ rescue StandardError => e
266
+ warn "end-intent: hollow-report gate crashed (#{e.message}); proceeding without it"
267
+ nil
268
+ end
269
+
270
+ # 317a S7 (D5, A8): stamp the live lock's run_mode into outcome.md frontmatter
271
+ # INSIDE the close step - after the gate, before the INDEX move and the store
272
+ # commit - so the stamped file rides the close commit and mode survives disarm.
273
+ def stamp_outcome_mode(intent_dir)
274
+ data = Lock.read(intent_dir)
275
+ run_mode = data && data["run_mode"].to_s
276
+ return if run_mode.nil? || run_mode.empty?
277
+
278
+ path = File.join(intent_dir, "outcome.md")
279
+ return unless File.exist?(path)
280
+ text = File.read(path)
281
+ return unless text.start_with?("---")
282
+ head, body = text[3..].split("---", 2)
283
+ return if body.nil? || head.match?(/^mode:/)
284
+
285
+ File.write(path, "---#{head}mode: #{run_mode}\n---#{body}")
286
+ rescue StandardError => e
287
+ warn "end-intent: mode stamp failed (#{e.message}); proceeding without it"
288
+ end
289
+
218
290
  def stamp_outcome_summary(content, summary)
291
+
219
292
  return content if summary.nil? || summary.to_s.strip.empty?
220
293
 
221
294
  lines = content.lines
@@ -654,6 +727,26 @@ def main(argv)
654
727
  reason = OutcomeGuard.reason(intent_dir, disposition)
655
728
  warn "end-intent: outcome.md: #{reason} (proceeding; doctor --intent #{id} reports it)" if reason
656
729
 
730
+ # 1a2. The self-render gate (317a S9, D7): refuse a hollow delivered close
731
+ # BEFORE any terminal mutation - INDEX, savepoint, and store stay untouched
732
+ # on exit 7 (A9).
733
+ hollow = hollow_report_reason(intent_dir, disposition)
734
+ if hollow
735
+ if opts[:allow_hollow_report]
736
+ warn "end-intent: hollow delivered report overridden (--allow-hollow-report): #{hollow}"
737
+ else
738
+ warn "end-intent: refusing a hollow delivered close: #{hollow}"
739
+ warn "end-intent: write outcome.md's ## Delivered as the labeled | Row | What | table " \
740
+ "matching the action-file headings (templates/outcome.md shows the shape), " \
741
+ "or pass --allow-hollow-report to close anyway"
742
+ exit 7
743
+ end
744
+ end
745
+
746
+ # 1a3. Mode stamp (317a S7, D5): run_mode into outcome frontmatter while the
747
+ # lock still exists, so the stamped file rides the close commit.
748
+ stamp_outcome_mode(intent_dir)
749
+
657
750
  # 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
658
751
  if opts[:outcome_summary]
659
752
  intent_file = Savepoint.intent_file(intent_dir)
@@ -31,7 +31,8 @@ end
31
31
  exit 0 unless payload.is_a?(Hash)
32
32
 
33
33
  session_id = payload["session_id"].to_s
34
- prompt = payload["user_prompt"].to_s
34
+ prompt = payload["prompt"].to_s
35
+ prompt = payload["user_prompt"].to_s if prompt.empty?
35
36
  cwd = payload["cwd"].to_s
36
37
  cwd = Dir.pwd if cwd.empty?
37
38
 
@@ -128,11 +129,20 @@ def truncate(text, max)
128
129
  "#{text[0, max - 3]}..."
129
130
  end
130
131
 
131
- # --- (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.
132
140
  begin
133
- SessionLedger.ensure_tmp_root(store)
134
- FileUtils.mkdir_p(SessionLedger.session_tmp_dir(store, sid))
135
- 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
136
146
  rescue StandardError
137
147
  nil
138
148
  end
@@ -150,11 +160,14 @@ rescue StandardError
150
160
  end
151
161
 
152
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.
153
166
  unless skip_day_ledger
154
167
  begin
155
- project = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
156
- clean = truncate(SessionLedger.sanitize_summary(prompt), 120)
157
- 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)
158
171
  SessionLedger.open_day(store: store, day: today, templates: templates, author: sid)
159
172
  line = SessionLedger.checklist_line(:pending, sid, project, clean)
160
173
  SessionLedger.append_line(SessionLedger.checklist_path(store, today), line,
@@ -25,9 +25,11 @@ payload =
25
25
 
26
26
  store = File.join(File.expand_path(plastic_home), "store")
27
27
  filer = File.expand_path("file-session-intent", __dir__)
28
+ templates = File.expand_path("../templates", __dir__)
28
29
  begin
29
30
  SessionClose.run(payload: payload, store: store, today: SessionLedger.day_id,
30
- spawner: SessionClose.default_spawner(filer))
31
+ spawner: SessionClose.default_spawner(filer),
32
+ handoff: SessionClose.default_handoff(templates))
31
33
  rescue StandardError
32
34
  nil
33
35
  end
@@ -0,0 +1,74 @@
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
+ # Claude adapter: Claude Code only; the core is harness-agnostic.
17
+
18
+ # 317a (B1): the requires sit inside their own rescue - a missing or broken
19
+ # lib file must never surface as a non-zero exit or stderr on a chunk of an
20
+ # ordinary message (LoadError is a ScriptError, outside StandardError).
21
+ begin
22
+ require "json"
23
+ require "time"
24
+ require "tmpdir"
25
+ require "yaml"
26
+ require_relative "lib/message_display"
27
+ rescue ScriptError, StandardError
28
+ exit 0
29
+ end
30
+
31
+ def color_enabled?(plastic_home)
32
+ return false unless ENV["NO_COLOR"].to_s.empty?
33
+
34
+ cfg_path = File.join(plastic_home, "config.yml")
35
+ return true unless File.exist?(cfg_path)
36
+
37
+ cfg = YAML.safe_load(File.read(cfg_path))
38
+ display = cfg.is_a?(Hash) ? cfg["display"] : nil
39
+ return true unless display.is_a?(Hash)
40
+
41
+ display.fetch("ansi_screen", true) != false
42
+ rescue StandardError
43
+ true
44
+ end
45
+
46
+ begin
47
+ raw = $stdin.tty? ? "" : $stdin.read
48
+ payload = raw.to_s.strip.empty? ? nil : JSON.parse(raw)
49
+
50
+ if payload.is_a?(Hash)
51
+ tmp_root = ENV["PLASTIC_TMP"].to_s.empty? ? Dir.tmpdir : ENV["PLASTIC_TMP"]
52
+ plastic_home = File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
53
+
54
+ handler = MessageDisplay.new(
55
+ tmp_root: tmp_root,
56
+ plastic_home: plastic_home,
57
+ color: color_enabled?(plastic_home),
58
+ now: Time.now,
59
+ )
60
+ result = handler.handle(payload)
61
+
62
+ if result.is_a?(String)
63
+ puts JSON.generate(
64
+ "hookSpecificOutput" => {
65
+ "hookEventName" => "MessageDisplay",
66
+ "displayContent" => result,
67
+ },
68
+ )
69
+ end
70
+ end
71
+ rescue StandardError
72
+ nil
73
+ end
74
+ 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