@zalom/plastic 2.0.0-alpha.4 → 2.0.0-alpha.5

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/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.5",
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,
@@ -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))
@@ -18,6 +18,18 @@ require_relative "session_ledger"
18
18
  module DoctorSessionLedger
19
19
  ORPHAN_TTL_SECONDS = 24 * 60 * 60
20
20
 
21
+ # Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
22
+ # DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
23
+ # session start never ran at all (a `-p` print session, a resumed
24
+ # background job, an unregistered SessionStart hook), not a session that
25
+ # ran and then never closed. No choice of session id ever creates a
26
+ # pointer for that class, so it would otherwise stay invisible for the
27
+ # full 24 hours (or forever, once hook-capture/hook-record stop creating
28
+ # it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
29
+ # between its own start and its first pointer write is normal and must not
30
+ # be flagged, but that window is seconds, not hours.
31
+ NO_POINTER_TTL_SECONDS = 5 * 60
32
+
21
33
  # Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
22
34
  # else that file's mtime, else the directory's mtime.
23
35
  def heartbeat_age(dir, now)
@@ -37,6 +49,7 @@ module DoctorSessionLedger
37
49
 
38
50
  store_dir = File.join(plastic_home, "store")
39
51
  orphans = orphaned_session_dirs(store_dir, now)
52
+ no_pointer = no_pointer_session_dirs(store_dir, now)
40
53
  shape = day_ledger_shape_problems(store_dir)
41
54
 
42
55
  checks = []
@@ -54,6 +67,20 @@ module DoctorSessionLedger
54
67
  "session is gone: a live session rewrites its heartbeat on every " \
55
68
  "prompt and edit, so only a listed directory may be removed, by hand.")
56
69
  end
70
+ checks << if no_pointer.empty?
71
+ check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
72
+ message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
73
+ "longer than #{NO_POINTER_TTL_SECONDS} seconds")
74
+ else
75
+ check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
76
+ message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
77
+ "with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
78
+ "(session start never ran for this session)",
79
+ details: no_pointer, fixable: true,
80
+ fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
81
+ "session never started: no `current` pointer means session start never " \
82
+ "ran for it, so this is not a live session missing a checklist entry.")
83
+ end
57
84
  checks << if shape.empty?
58
85
  check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
59
86
  message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
@@ -86,6 +113,31 @@ module DoctorSessionLedger
86
113
  [] # unreadable .tmp/: nothing to report, never a crash
87
114
  end
88
115
 
116
+ # Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
117
+ # NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
118
+ # that one is age-only and blind to whether a pointer exists at all, so a
119
+ # young no-pointer dir (a session between its start and its first pointer
120
+ # write) must not appear here even though it may well appear there once it
121
+ # ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
122
+ # and a dir can legitimately show up in neither, either, or both.
123
+ def no_pointer_session_dirs(store_dir, now)
124
+ tmp_root = SessionLedger.tmp_root(store_dir)
125
+ return [] unless File.directory?(tmp_root)
126
+
127
+ Dir.children(tmp_root).sort.filter_map do |name|
128
+ dir = File.join(tmp_root, name)
129
+ next unless File.directory?(dir)
130
+ next if File.exist?(File.join(dir, "current"))
131
+
132
+ age = heartbeat_age(dir, now)
133
+ next if age <= NO_POINTER_TTL_SECONDS
134
+
135
+ "global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
136
+ end
137
+ rescue SystemCallError
138
+ [] # unreadable .tmp/: nothing to report, never a crash
139
+ end
140
+
89
141
  def day_ledger_shape_problems(store_dir)
90
142
  root = SessionLedger.sessions_root(store_dir)
91
143
  return [] unless File.directory?(root)
@@ -76,8 +76,25 @@ class InstallerCore
76
76
  @agents = agents
77
77
  end
78
78
 
79
+ # Reads the package version from `package.json` when present (the source
80
+ # tree, and any dev checkout), falling back to the bare `VERSION` file the
81
+ # installer copies to `~/.plastic` (row B, spec 315b): an installed system
82
+ # carries VERSION but never package.json, so `rollback.rb` and `update.rb`
83
+ # constructing an InstallerCore with `package_root: ~/.plastic` crashed with
84
+ # a raw Errno::ENOENT before this fallback existed. Raises a named error,
85
+ # not a bare ENOENT, when neither file exists, so the caller is not
86
+ # misdirected toward the wrong missing file.
79
87
  def read_package_version(root)
80
- File.read(File.join(root, "package.json")).then { |s| JSON.parse(s)["version"] }
88
+ package_json = File.join(root, "package.json")
89
+ version_file = File.join(root, "VERSION")
90
+
91
+ if File.exist?(package_json)
92
+ JSON.parse(File.read(package_json))["version"]
93
+ elsif File.exist?(version_file)
94
+ File.read(version_file).strip
95
+ else
96
+ raise "cannot determine the package version: neither #{package_json} nor #{version_file} exists"
97
+ end
81
98
  end
82
99
 
83
100
  # --- Channel derivation (the channel is encoded in the version string) ---
@@ -207,11 +207,37 @@ module SessionGit
207
207
 
208
208
  # --- commit message ------------------------------------------------------------
209
209
 
210
- # The first line of `summary`, truncated to MAX_SUBJECT_LENGTH characters,
211
- # with no trailer (spec D5).
210
+ # The first line of `summary`, cut at the last word boundary at or before
211
+ # MAX_SUBJECT_LENGTH characters, falling back to the hard slice when no
212
+ # boundary exists at or before the limit (spec D6, amends spec 300 D5's
213
+ # unconditional `first_line[0, MAX_SUBJECT_LENGTH]`, which cut mid-word).
214
+ # A subject at or under the limit is returned unchanged. Post-execution
215
+ # review item 7: when the character immediately after the 72-char prefix
216
+ # is ITSELF a space, the prefix already ends exactly on a word boundary and
217
+ # needs no trimming at all -- checked before consulting `rindex`, because
218
+ # an earlier internal space inside the 72-char prefix would otherwise make
219
+ # `rindex` walk back past a whole trailing word that fit perfectly.
212
220
  def subject_for(summary)
213
221
  first_line = summary.to_s.split(/\r?\n/, 2).first.to_s.strip
214
- first_line[0, MAX_SUBJECT_LENGTH]
222
+ return first_line if first_line.length <= MAX_SUBJECT_LENGTH
223
+
224
+ cut = first_line[0, MAX_SUBJECT_LENGTH]
225
+ return cut if first_line[MAX_SUBJECT_LENGTH] == " "
226
+
227
+ boundary = cut.rindex(" ")
228
+ boundary ? cut[0, boundary] : cut
229
+ end
230
+
231
+ # The commit body for `summary`/`subject` (spec D6): the full summary when
232
+ # `subject` is a cut-down copy of it, nil when the subject already carries
233
+ # the summary whole (no redundant body on a short, single-line summary).
234
+ # Post-execution review item 4: compares the STRIPPED raw summary, not the
235
+ # raw summary verbatim -- a summary with only trailing/leading whitespace
236
+ # around an otherwise-identical subject must not repeat the same sentence
237
+ # twice as a redundant body.
238
+ def body_for(summary, subject)
239
+ raw = summary.to_s
240
+ raw.strip == subject ? nil : raw
215
241
  end
216
242
 
217
243
  # --- git primitives (all use -C, never cwd) -------------------------------------
@@ -273,9 +299,11 @@ module SessionGit
273
299
  parts.each_cons(2).any? { |a, b| a == ".claude" && b == "worktrees" }
274
300
  end
275
301
 
276
- def stage_and_commit(dir, subject, runner:)
302
+ def stage_and_commit(dir, subject, runner:, body: nil)
277
303
  runner.run("-C", dir, "add", "-A")
278
- runner.run("-C", dir, "commit", "-m", subject)
304
+ args = ["-C", dir, "commit", "-m", subject]
305
+ args += ["-m", body] if body
306
+ runner.run(*args)
279
307
  end
280
308
 
281
309
  def short_sha(dir, runner:)
@@ -310,14 +338,16 @@ module SessionGit
310
338
 
311
339
  flow, flow_notes = load_flow(cwd: cwd, repo: repo, plastic_home: plastic_home, runner: runner)
312
340
  subject = subject_for(summary)
341
+ body = body_for(summary, subject)
313
342
 
314
343
  result =
315
344
  if flow["mode"] == "pull_request"
316
- commit_pull_request(repo: repo, subject: subject, day: day, session: session,
345
+ commit_pull_request(repo: repo, subject: subject, body: body, day: day, session: session,
317
346
  store: effective_store, flow: flow, branch_now: branch_now,
318
347
  runner: runner, gh_runner: gh_runner)
319
348
  else
320
- commit_direct(repo: repo, subject: subject, day: day, flow: flow, branch_now: branch_now, runner: runner)
349
+ commit_direct(repo: repo, subject: subject, body: body, day: day, flow: flow, branch_now: branch_now,
350
+ runner: runner)
321
351
  end
322
352
 
323
353
  return result if flow_notes.empty?
@@ -331,7 +361,7 @@ module SessionGit
331
361
 
332
362
  # --- direct mode (spec D3) --------------------------------------------------------
333
363
 
334
- def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:)
364
+ def commit_direct(repo:, subject:, day:, flow:, branch_now:, runner:, body: nil)
335
365
  return note("nothing to commit") unless dirty?(repo, runner: runner)
336
366
  return note("summary is empty after truncation: no commit") if blank?(subject)
337
367
 
@@ -351,10 +381,10 @@ module SessionGit
351
381
  end
352
382
 
353
383
  if branch_now == base || branch_now == session_branch
354
- commit_on_session_branch(repo: repo, subject: subject, base: base,
384
+ commit_on_session_branch(repo: repo, subject: subject, base: base, body: body,
355
385
  session_branch: session_branch, branch_now: branch_now, runner: runner)
356
386
  else
357
- commit_on_other_branch(repo: repo, subject: subject, branch_now: branch_now, runner: runner)
387
+ commit_on_other_branch(repo: repo, subject: subject, body: body, branch_now: branch_now, runner: runner)
358
388
  end
359
389
  end
360
390
 
@@ -367,7 +397,7 @@ module SessionGit
367
397
  # session branch. `current_branch` is re-read after the switch and used
368
398
  # for the commit message instead of trusting the branch this method
369
399
  # intended to reach.
370
- def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:)
400
+ def commit_on_session_branch(repo:, subject:, base:, session_branch:, branch_now:, runner:, body: nil)
371
401
  unless branch_exists?(repo, session_branch, runner: runner)
372
402
  create = runner.run("-C", repo, "branch", session_branch, base.to_s)
373
403
  return note("could not create session branch #{session_branch}: #{diagnose(create)}") unless create.success?
@@ -383,11 +413,12 @@ module SessionGit
383
413
  return note("expected to be on #{session_branch} but the checkout is on #{actual_branch.inspect}")
384
414
  end
385
415
 
386
- commit_and_push(dir: repo, push_dir: repo, subject: subject, from: actual_branch, base: base, runner: runner)
416
+ commit_and_push(dir: repo, push_dir: repo, subject: subject, body: body, from: actual_branch, base: base,
417
+ runner: runner)
387
418
  end
388
419
 
389
- def commit_on_other_branch(repo:, subject:, branch_now:, runner:)
390
- res = stage_and_commit(repo, subject, runner: runner)
420
+ def commit_on_other_branch(repo:, subject:, branch_now:, runner:, body: nil)
421
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
391
422
  return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
392
423
 
393
424
  sha = short_sha(repo, runner: runner)
@@ -398,8 +429,8 @@ module SessionGit
398
429
  # shared tail. A non-fast-forward push (spec D3, "base moved ahead
399
430
  # independently") stays a Note: the commit itself already landed on the
400
431
  # session branch.
401
- def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:)
402
- res = stage_and_commit(dir, subject, runner: runner)
432
+ def commit_and_push(dir:, push_dir:, subject:, from:, base:, runner:, body: nil)
433
+ res = stage_and_commit(dir, subject, runner: runner, body: body)
403
434
  return note("commit rejected by commit-msg hook: #{diagnose(res)}") unless res.success?
404
435
 
405
436
  sha = short_sha(dir, runner: runner)
@@ -414,7 +445,7 @@ module SessionGit
414
445
 
415
446
  # --- pull request mode (spec D4) ------------------------------------------------
416
447
 
417
- def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:)
448
+ def commit_pull_request(repo:, subject:, day:, session:, store:, flow:, branch_now:, runner:, gh_runner:, body: nil)
418
449
  return note("nothing to commit") unless dirty?(repo, runner: runner)
419
450
  return note("summary is empty after truncation: no commit") if blank?(subject)
420
451
 
@@ -439,7 +470,7 @@ module SessionGit
439
470
  return note("could not check out branch #{branch}: #{diagnose(switch)}") unless switch.success?
440
471
  end
441
472
 
442
- res = stage_and_commit(repo, subject, runner: runner)
473
+ res = stage_and_commit(repo, subject, runner: runner, body: body)
443
474
  outcome =
444
475
  if res.success?
445
476
  pull_request_outcome(repo: repo, subject: subject, branch: branch, base: base, gh_runner: gh_runner, runner: runner)
@@ -186,6 +186,130 @@ module SessionLedger
186
186
  "#{collapsed[0, 197]}..."
187
187
  end
188
188
 
189
+ # --- capture_worthy? (spec D2, D7; supersedes 298 D2(c)) -------------------
190
+
191
+ # One complete top-level harness envelope tag block: "<name ...>...</name>".
192
+ # Non-greedy (.*?) so sibling blocks are each matched on their own rather
193
+ # than one match spanning from the first block's opening tag all the way to
194
+ # the LAST block's closing tag (post-execution review item 3: an envelope
195
+ # on both sides of real work, "<system-reminder>...</system-reminder>\nfix
196
+ # the parser\n<task-notification>...</task-notification>", must not be
197
+ # read as one giant envelope swallowing the work in the middle).
198
+ ENVELOPE_BLOCK_RE = /<([A-Za-z][\w-]*)(?:\s[^>]*)?>.*?<\/\1>/m
199
+ private_constant :ENVELOPE_BLOCK_RE
200
+
201
+ # The whole prompt, case- and whitespace-insensitively, and nothing else
202
+ # (rule 3, D2): a trigger word inside a longer real instruction ("continue
203
+ # the dashboard fix and then release") must not match this.
204
+ BARE_TRIGGERS = %w[continue auto].freeze
205
+ private_constant :BARE_TRIGGERS
206
+
207
+ # Words whose presence marks a prompt as actionable work (rule 4's escape
208
+ # hatch, D2's accept bias). Deliberately excludes common nouns that also
209
+ # read as everyday verbs in casual remarks (e.g. "release", "ship", "plan"):
210
+ # including them would make ordinary conversation about a past release or
211
+ # plan look like a work request. Matched with an optional inflection suffix
212
+ # (post-execution review BLOCKER): the bare stems alone missed "fixed",
213
+ # "updated", "added", "reviewed", "implemented" -- exactly the past-tense
214
+ # and -ing forms real work summaries use.
215
+ WORK_MARKER_WORDS = %w[
216
+ fix add remove delete update upgrade implement write build create refactor
217
+ debug investigate review test deploy commit merge revert rename configure
218
+ install migrate document generate draft resolve help need want make change
219
+ setup
220
+ ].freeze
221
+ private_constant :WORK_MARKER_WORDS
222
+
223
+ WORK_MARKER_PHRASES = [
224
+ "can you", "could you", "would you", "let's", "let us", "set up", "look into", "figure out",
225
+ ].freeze
226
+ private_constant :WORK_MARKER_PHRASES
227
+
228
+ WORK_MARKER_RE = /\b(?:#{WORK_MARKER_WORDS.join("|")})(?:s|d|ed|ing)?\b/i
229
+ private_constant :WORK_MARKER_RE
230
+
231
+ # A first word that reads as an interrogative opener, checked case-
232
+ # insensitively against the prompt's first whitespace-separated token.
233
+ # Post-execution review BLOCKER: trimmed from the original, wider list
234
+ # (which also carried "how", "when", "where", "was", "were", "do", "did",
235
+ # "will", "shall", "should") down to words that open a genuine QUESTION at
236
+ # least as often as an ordinary command or request. Measured against 43
237
+ # invented and 27 real day-ledger prompts: the dropped words open ordinary
238
+ # work requests ("do the release now...", "when you are done, tag the
239
+ # release...", "will you push that branch...", "should I bump the version
240
+ # files...") far more often than they open a bare question worth rejecting.
241
+ QUESTION_STARTERS = %w[
242
+ what why who whom whose which is are am does can could would
243
+ ].freeze
244
+ private_constant :QUESTION_STARTERS
245
+
246
+ # A narrow set of retrospective-remark shapes ("that release went smoother
247
+ # than the last one"): comparative or evaluative observations about how
248
+ # something already went. Deliberately narrow (D2's accept bias): a broad
249
+ # "any declarative sentence with no recognized verb" rule would also catch
250
+ # ordinary work summaries like "harness text wins the pending line", which
251
+ # must stay accepted.
252
+ REMARK_PATTERNS = [
253
+ /\bwent\s+\w+\s+than\b/i,
254
+ /\bwent\s+(?:well|badly|smoothly|great|poorly|terribly)\b/i,
255
+ ].freeze
256
+ private_constant :REMARK_PATTERNS
257
+
258
+ # True iff nothing but harness envelope tag block(s) -- and whitespace --
259
+ # remain once every complete top-level block is stripped out. A prompt
260
+ # that is one envelope alone, or several envelopes with no other content,
261
+ # matches; a prompt carrying real work anywhere outside an envelope (before,
262
+ # after, or between several of them) does not.
263
+ def whole_prompt_envelope?(stripped)
264
+ stripped.gsub(ENVELOPE_BLOCK_RE, "").strip.empty?
265
+ end
266
+
267
+ def bare_trigger?(stripped)
268
+ BARE_TRIGGERS.include?(stripped.downcase)
269
+ end
270
+
271
+ def work_marker?(text)
272
+ return true if WORK_MARKER_RE.match?(text)
273
+
274
+ downcased = text.downcase
275
+ WORK_MARKER_PHRASES.any? { |p| downcased.include?(p) }
276
+ end
277
+
278
+ def interrogative?(stripped)
279
+ return true if stripped.end_with?("?")
280
+
281
+ first_word = stripped.split(/\s+/).first.to_s.downcase.gsub(/[^a-z]/, "")
282
+ QUESTION_STARTERS.include?(first_word)
283
+ end
284
+
285
+ def bare_remark?(stripped)
286
+ REMARK_PATTERNS.any? { |re| re.match?(stripped) }
287
+ end
288
+
289
+ # Internal helpers only: #capture_worthy? is the sole public contract
290
+ # (post-execution review item 9).
291
+ private_class_method :whole_prompt_envelope?, :bare_trigger?, :work_marker?, :interrogative?, :bare_remark?
292
+
293
+ # Whether `prompt` earns a pending checklist line (spec D2). Bias is
294
+ # ACCEPT: this rejects only on four named rules -- the 10-char floor (on
295
+ # its own collapsed copy), a whole-prompt harness envelope, a bare
296
+ # continue/auto trigger, and an interrogative or bare-remark prompt
297
+ # carrying no work marker -- and accepts everything else, including a
298
+ # work-shaped question and an envelope followed by real work. Takes the
299
+ # RAW prompt (not the sanitized/truncated line text) so rule 2 sees the
300
+ # prompt's true first character and multi-line shape.
301
+ def capture_worthy?(prompt)
302
+ raw = prompt.to_s
303
+ return false if sanitize_summary(raw).length < 10
304
+
305
+ stripped = raw.strip
306
+ return false if whole_prompt_envelope?(stripped)
307
+ return false if bare_trigger?(stripped)
308
+ return false if !work_marker?(stripped) && (interrogative?(stripped) || bare_remark?(stripped))
309
+
310
+ true
311
+ end
312
+
189
313
  # One LF-terminated checklist line, byte exact per spec D5. The state
190
314
  # marker is fixed width across all three states, which is what lets a later
191
315
  # promote or tick be a one-byte write at a known offset.