@zalom/plastic 2.0.0-alpha.12 → 2.0.0-alpha.13

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.12",
3
+ "version": "2.0.0-alpha.13",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,8 +10,10 @@
10
10
  # renderer path as `renderer_path:` (D2).
11
11
  require "time"
12
12
  require "json"
13
+ require "date"
13
14
  require_relative "intent_screen"
14
15
  require_relative "lock"
16
+ require_relative "session_ledger"
15
17
 
16
18
  module ReportScreen
17
19
  NOT_RECORDED = "not recorded"
@@ -73,10 +75,52 @@ module ReportScreen
73
75
  Array(frontmatter(intent_dir)["tags"]).map(&:to_s).include?("research")
74
76
  end
75
77
 
78
+ # Intent 330 (D12): the shared fence walker feeding split_by_headings AND
79
+ # table_rows. A line matching \A\s{0,3}(```+|~~~+) while closed opens a
80
+ # fence and remembers the marker character and its length; while open, a
81
+ # line whose marker is the SAME character and at least as long, with only
82
+ # whitespace after it, closes the fence. Inside a fence every line is body
83
+ # - a leading "#" or a leading "|" included. A four-space-indented block is
84
+ # deliberately never a fence (the cap is 0-3 leading whitespace chars),
85
+ # which is the CommonMark indented-code case, out of scope on purpose
86
+ # (D12's stated limit). Yields [line, fenced] for every line, in order.
87
+ FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
88
+
89
+ def self.each_fence_line(text)
90
+ return enum_for(:each_fence_line, text) unless block_given?
91
+
92
+ marker = nil # [character, length] of the currently open fence, or nil
93
+ text.to_s.each_line do |line|
94
+ if marker
95
+ yield line, true
96
+ m = line.match(FENCE_LINE_RE)
97
+ next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
98
+ next unless line.sub(FENCE_LINE_RE, "").strip.empty?
99
+
100
+ marker = nil
101
+ else
102
+ m = line.match(FENCE_LINE_RE)
103
+ if m
104
+ marker = [m[1][0], m[1].length]
105
+ yield line, true
106
+ else
107
+ yield line, false
108
+ end
109
+ end
110
+ end
111
+ end
112
+
76
113
  # Markdown pipe-table data rows (header + separator skipped), each an array
77
- # of trimmed cell strings. Tolerates leading prose before the table.
114
+ # of trimmed cell strings. Tolerates leading prose before the table. Fence-
115
+ # aware (D12/O1.7): a pipe row inside a fenced example is never counted.
78
116
  def self.table_rows(text)
79
- lines = text.to_s.lines.map(&:strip).select { |l| l.start_with?("|") }
117
+ lines = []
118
+ each_fence_line(text) do |line, fenced|
119
+ next if fenced
120
+
121
+ stripped = line.strip
122
+ lines << stripped if stripped.start_with?("|")
123
+ end
80
124
  sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
81
125
  return [] unless sep_idx
82
126
  lines[(sep_idx + 1)..].map { |l| l.split("|", -1).map(&:strip)[1..-2].to_a }
@@ -84,13 +128,14 @@ module ReportScreen
84
128
 
85
129
  # Every [heading_line, body] pair in a Markdown file, split on ANY heading
86
130
  # line (any level). Used by proven_by (D19) so a section's own matrix rows
87
- # are never confused with a sibling section's.
131
+ # are never confused with a sibling section's. Fence-aware (D12): a "#"
132
+ # line inside a fenced example never starts a new section.
88
133
  def self.split_by_headings(text)
89
134
  sections = []
90
135
  heading = nil
91
136
  body = +""
92
- text.to_s.each_line do |line|
93
- if line.start_with?("#")
137
+ each_fence_line(text) do |line, fenced|
138
+ if !fenced && line.start_with?("#")
94
139
  sections << [heading, body] if heading
95
140
  heading = line.strip
96
141
  body = +""
@@ -327,13 +372,64 @@ module ReportScreen
327
372
  line && line.match(/\b([0-9a-f]{7,40})\b/)[1]
328
373
  end
329
374
 
330
- def self.ship_row(_text, intent_dir, tag_reader)
375
+ # Intent 330 (D9): reads `flow: base:` from a project's project.yml when
376
+ # `intent_dir` sits in the installed project layout
377
+ # (<home>/projects/<slug>/store/<id--slug>); nil otherwise (a global-store
378
+ # intent, a project with no `flow:` key, or malformed YAML). Pure: no git,
379
+ # no shell-out, just the one file this intent's own layout already reads.
380
+ PROJECT_LAYOUT_RE = %r{\A(.*)/projects/([^/]+)/store/[^/]+\z}.freeze
381
+
382
+ def self.flow_base(intent_dir)
383
+ m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
384
+ return nil unless m
385
+
386
+ home, slug = m[1], m[2]
387
+ path = File.join(home, "projects", slug, "project.yml")
388
+ return nil unless File.exist?(path)
389
+
390
+ require "yaml"
391
+ data = YAML.safe_load(File.read(path))
392
+ return nil unless data.is_a?(Hash)
393
+
394
+ flow = data["flow"]
395
+ return nil unless flow.is_a?(Hash)
396
+
397
+ base = flow["base"]
398
+ base.is_a?(String) && !base.empty? ? base : nil
399
+ rescue StandardError
400
+ nil
401
+ end
402
+
403
+ # Intent 330 (D9/D10/D23): the ship row's WHAT cell is the merge sha, then
404
+ # " → <branch>" only when `branch_reader` answers one (never the "alpha"
405
+ # literal), then " · v<version>" or the existing not-recorded fallback. The
406
+ # Source cell names WHERE the branch came from (D23): project.yml when
407
+ # flow_base itself supplied that exact branch, else git refs, so the row
408
+ # never keeps the stale "git tags" literal for a branch git never answered.
409
+ def self.ship_row(_text, intent_dir, tag_reader, branch_reader: ->(_dir) { nil })
331
410
  sha = merge_sha(intent_dir)
332
411
  version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
333
412
  return nil if sha.nil? && (version.nil? || version.to_s.empty?)
334
- ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
413
+ branch = branch_reader.call(intent_dir)
335
414
  sha_text = sha || NOT_RECORDED
336
- { kind: "ship", what: "#{sha_text} → alpha · #{ver_text}", source: "outcome.md; git tags" }
415
+ what = +sha_text
416
+ what << " → #{branch}" if branch && !branch.to_s.empty?
417
+ # D10: the version segment is omitted, not filled with NOT_RECORDED. A
418
+ # repository with no release line has no version, the header already
419
+ # carries the shipped identity, and naming the absence twice on one screen
420
+ # is the defect this intent was opened to remove, not a floor worth keeping.
421
+ what << " · v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
422
+ # D14: the cell names every file the row actually came from. The branch and
423
+ # the version have different origins, so when both contributed, both are
424
+ # named rather than only the branch's.
425
+ sources = ["outcome.md"]
426
+ if branch && !branch.to_s.empty?
427
+ sources << (flow_base(intent_dir) == branch ? "project.yml" : "git refs")
428
+ end
429
+ sources << "git tags" if version && !version.to_s.empty? && shipped_version(intent_dir).nil?
430
+ sources << "git tags" if sources.length == 1
431
+ source = sources.join("; ")
432
+ { kind: "ship", what: what, source: source }
337
433
  end
338
434
 
339
435
  def self.doctor_row(text)
@@ -362,7 +458,7 @@ module ReportScreen
362
458
  { kind: "verdict", what: m[1].strip, source: "outcome.md" }
363
459
  end
364
460
 
365
- def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil })
461
+ def self.evidence_rows(intent_dir, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
366
462
  text = outcome_text(intent_dir)
367
463
  return [] unless text
368
464
  verification = section_of(text, "## Verification")
@@ -370,7 +466,7 @@ module ReportScreen
370
466
  rows = []
371
467
  rows << suite_row(verification)
372
468
  rows << red_row(verification)
373
- rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader))
469
+ rows << (research_intent?(intent_dir) ? nil : ship_row(text, intent_dir, tag_reader, branch_reader: branch_reader))
374
470
  if research_intent?(intent_dir)
375
471
  rows << deposits_row(text)
376
472
  rows << verdict_row(text)
@@ -444,7 +540,11 @@ module ReportScreen
444
540
 
445
541
  # --- roster (D7/D8) -------------------------------------------------------------
446
542
 
447
- def self.active_dirnames(index_path)
543
+ # The dirnames named under one "## <section_name>" heading of an INDEX.md.
544
+ # active_dirnames used to hardcode "Active"; intent 330's session verb (D22)
545
+ # reuses this to find Completed/Abandoned dirnames for the no-bookend
546
+ # footer, so the section is now a parameter.
547
+ def self.dirnames_in_section(index_path, section_name)
448
548
  return [] unless File.exist?(index_path)
449
549
  dirnames = []
450
550
  section = nil
@@ -453,13 +553,24 @@ module ReportScreen
453
553
  section = line[3..].strip
454
554
  next
455
555
  end
456
- next unless section == "Active"
556
+ next unless section == section_name
457
557
  m = line.match(%r{\(store/([^/]+)/})
458
558
  dirnames << m[1] if m
459
559
  end
460
560
  dirnames
461
561
  end
462
562
 
563
+ def self.active_dirnames(index_path)
564
+ dirnames_in_section(index_path, "Active")
565
+ end
566
+
567
+ # Intent 330 (D22): both terminal sections count as "completed" for the
568
+ # no-bookend footer - a closed intent the reader cannot expect a Done
569
+ # savepoint line from, since the convention predates end-intent writing it.
570
+ def self.completed_dirnames(index_path)
571
+ dirnames_in_section(index_path, "Completed") + dirnames_in_section(index_path, "Abandoned")
572
+ end
573
+
463
574
  def self.newest_savepoint_ts(intent_dir)
464
575
  lines = savepoint_lines(intent_dir)
465
576
  lines.last&.first
@@ -541,18 +652,31 @@ module ReportScreen
541
652
  done ? human_time(done[0]) : NOT_RECORDED
542
653
  end
543
654
 
544
- def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil })
655
+ # Intent 330 (D11): the header's last segment is the shipped identity, and
656
+ # says which kind it is - v<version> when a version is known, else
657
+ # "merge <sha>" (never a bare, ambiguous hash), else the exact NOT_RECORDED
658
+ # string when neither exists.
659
+ def self.header_ship_segment(intent_dir, tag_reader)
660
+ version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
661
+ return "v#{version.to_s.sub(/\Av/, '')}" if version && !version.to_s.empty?
662
+
663
+ sha = merge_sha(intent_dir)
664
+ return "merge #{sha}" if sha && !sha.to_s.empty?
665
+
666
+ NOT_RECORDED
667
+ end
668
+
669
+ def self.render_delivered(intent_dir:, tag_reader: ->(_dir) { nil }, branch_reader: ->(_dir) { nil })
545
670
  id = intent_id(intent_dir)
546
671
  name = title_for(intent_dir, default_store_root(intent_dir))
547
672
  ts = delivered_timestamp(intent_dir)
548
673
  m = mode(intent_dir)
549
674
  dur = duration(intent_dir)
550
- version = shipped_version(intent_dir) || tag_reader.call(intent_dir)
551
- ver_text = version && !version.to_s.empty? ? "v#{version.to_s.sub(/\Av/, '')}" : NOT_RECORDED
675
+ ship_segment = header_ship_segment(intent_dir, tag_reader)
552
676
 
553
677
  lines = []
554
678
  lines << "## ✔ #{id} · #{name} · delivered"
555
- lines << "#{ts} · #{m} · #{dur} · #{ver_text}"
679
+ lines << "#{ts} · #{m} · #{dur} · #{ship_segment}"
556
680
  lines << ""
557
681
  lines << "**Asked**"
558
682
  lines << " #{asked(intent_dir)}"
@@ -566,7 +690,7 @@ module ReportScreen
566
690
  end
567
691
  lines << ""
568
692
  lines << "**Evidence**"
569
- ev = evidence_rows(intent_dir, tag_reader: tag_reader)
693
+ ev = evidence_rows(intent_dir, tag_reader: tag_reader, branch_reader: branch_reader)
570
694
  if ev.empty?
571
695
  # 317a S4 (matrix S4a): a header-only table (319's live rendering) says
572
696
  # nothing; the honest floor is the same phrase every other absent source
@@ -661,6 +785,184 @@ module ReportScreen
661
785
  "#{lines.join("\n")}\n"
662
786
  end
663
787
 
788
+ # --- S9: the session verb (intent 330) -------------------------------------------
789
+ #
790
+ # `report-screen session <tier_root>` - the delivered screens for every intent
791
+ # this session completed, oldest first, then the state --all roster (D1).
792
+ # Membership is the savepoint Done bookend inside [window_start, now] (D2),
793
+ # never the delivery lock (a dispatched lead's derived auto- key is not the
794
+ # owner's session id). The pure functions below take the clock and the
795
+ # ledger root as arguments (D8): no Time.now, no git, no ENV read here.
796
+
797
+ # <home> for a tier root, by the same layout discriminator IntentScreen
798
+ # uses elsewhere: a project tier root's parent directory is "projects".
799
+ def self.home_for_tier_root(tier_root)
800
+ File.basename(File.dirname(tier_root)) == "projects" ? File.expand_path("../..", tier_root) : tier_root
801
+ end
802
+
803
+ # D18: <home>/store/.sessions, derived from the tier root through the SAME
804
+ # discriminator - deriving it unconditionally from tier_root would answer
805
+ # "/Users" for the global tier (~/.plastic itself has no "store" segment
806
+ # to strip).
807
+ def self.default_ledger_root(tier_root)
808
+ File.join(home_for_tier_root(tier_root), "store", ".sessions")
809
+ end
810
+
811
+ # D5: "global" is <home> itself; any other slug is <home>/projects/<slug>.
812
+ def self.store_for_slug(home, slug)
813
+ slug == "global" ? home : File.join(home, "projects", slug)
814
+ end
815
+
816
+ # D4: the newest valid day directory that is not in the future, when
817
+ # `today`'s own day directory does not exist. No ledger at all (D3.13)
818
+ # answers `today` unchanged rather than raising - there is simply nothing
819
+ # to scan, not an error.
820
+ def self.fallback_day(ledger_root, today)
821
+ return today if Dir.exist?(File.join(ledger_root, today))
822
+ return today unless Dir.exist?(ledger_root)
823
+
824
+ candidates = Dir.children(ledger_root).select { |d| SessionLedger.valid_day_id?(d) && d <= today }
825
+ candidates.max || today
826
+ end
827
+
828
+ # D17: the visible note printed above the screens when no session id was
829
+ # given at all, so the whole-day, tier-only fallback never looks like a
830
+ # real, narrower answer.
831
+ # D17: shaped as a screen opener ("▶ ... · ...") on purpose. The note is the
832
+ # first line of the reply, and both ScreenPaint's OPENER_RE and the
833
+ # MessageDisplay hook's first-character gate require that shape; a plain
834
+ # sentence here would leave the whole session report unpainted.
835
+ def self.window_note(day, reason)
836
+ "▶ Window · the whole of #{Date.strptime(day, '%Y%m%d').iso8601} · #{reason}"
837
+ end
838
+
839
+ # True when the day ledger actually carries a line for this session, across
840
+ # the same two day directories the window search reads. The CLI asks so it
841
+ # can tell "no session id given" apart from "this session id matches no
842
+ # ledger line": D17 exists to stop the second one answering silently, and a
843
+ # resumed background job carries exactly that kind of unmatched id.
844
+ def self.session_tagged?(ledger_root:, session:, now:)
845
+ return false if session.nil? || session.to_s.strip.empty?
846
+
847
+ short = SessionLedger.short_session_id(session)
848
+ today = SessionLedger.day_id(now)
849
+ yesterday = SessionLedger.day_id(now - 86_400)
850
+ [yesterday, today].any? do |d|
851
+ session_ledger_lines(ledger_root, d).any? { |l| l[:session] == short }
852
+ end
853
+ end
854
+
855
+ # D4: local midnight of `day`, converted to UTC, using `sample_now`'s OWN
856
+ # utc_offset - never a literal UTC midnight, and never the machine's
857
+ # ambient zone outside what the injected clock itself carries.
858
+ def self.local_midnight_utc(day, sample_now)
859
+ date = Date.strptime(day, "%Y%m%d")
860
+ Time.new(date.year, date.month, date.day, 0, 0, 0, sample_now.utc_offset)
861
+ end
862
+
863
+ # One day's session-tagged savepoint lines: "{ts} {Event} [{session}]
864
+ # [{slug}] {summary}" (SessionLedger.savepoint_line's own shape). Missing
865
+ # file, or a line that does not match, is silently skipped.
866
+ SESSION_LEDGER_LINE_RE = /\A(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ)\s{2,}\S+\s{2,}\[([^\]]*)\]\s\[([^\]]*)\]/.freeze
867
+
868
+ def self.session_ledger_lines(ledger_root, day)
869
+ path = File.join(ledger_root, day, "savepoint.md")
870
+ return [] unless File.exist?(path)
871
+
872
+ File.readlines(path).filter_map do |line|
873
+ m = line.match(SESSION_LEDGER_LINE_RE)
874
+ m ? { ts: m[1], session: m[2], slug: m[3] } : nil
875
+ end
876
+ end
877
+
878
+ def self.store_intent_dirs(store)
879
+ Dir.glob(File.join(store, "store", "*")).select { |d| IntentScreen.intent_dir?(d) }
880
+ end
881
+
882
+ def self.last_done_ts(intent_dir)
883
+ lines = savepoint_lines(intent_dir)
884
+ done = lines.reverse.find { |_ts, kind, _text| kind == "Done" }
885
+ done ? Time.parse(done[0]) : nil
886
+ end
887
+
888
+ # D2/D3/D4/D5/D22: the intent directories completed inside the session's
889
+ # window, oldest Done bookend first, plus the count of completed intents
890
+ # (D22: Completed or Abandoned in INDEX.md) that carry no Done bookend at
891
+ # all and so cannot be placed in any window.
892
+ def self.session_delivered_dirs(ledger_root:, tier_root:, session:, since:, now:)
893
+ today = SessionLedger.day_id(now)
894
+ yesterday = SessionLedger.day_id(now - 86_400)
895
+ short = session && !session.to_s.strip.empty? ? SessionLedger.short_session_id(session) : nil
896
+
897
+ tagged = short ? [yesterday, today].flat_map { |d| session_ledger_lines(ledger_root, d) }
898
+ .select { |l| l[:session] == short } : []
899
+ slugs = tagged.map { |l| l[:slug] }.uniq
900
+
901
+ window_start =
902
+ if since
903
+ Time.parse(since.to_s)
904
+ elsif tagged.any?
905
+ tagged.map { |l| Time.parse(l[:ts]) }.min
906
+ else
907
+ local_midnight_utc(fallback_day(ledger_root, today), now)
908
+ end
909
+
910
+ home = home_for_tier_root(tier_root)
911
+ stores = ([tier_root] + slugs.map { |s| store_for_slug(home, s) }).uniq
912
+ stores = stores.select { |s| File.exist?(File.join(s, "INDEX.md")) }
913
+
914
+ entries = []
915
+ skipped = 0
916
+ stores.each do |store|
917
+ completed = completed_dirnames(File.join(store, "INDEX.md"))
918
+ store_intent_dirs(store).each do |dir|
919
+ done_ts = last_done_ts(dir)
920
+ if done_ts
921
+ entries << [dir, done_ts] if done_ts >= window_start && done_ts <= now
922
+ elsif completed.include?(File.basename(dir))
923
+ skipped += 1
924
+ end
925
+ end
926
+ end
927
+
928
+ [entries.sort_by { |_dir, ts| ts }.map(&:first), skipped]
929
+ end
930
+
931
+ # D1/D7/D21/D22: one delivered screen per directory (oldest first, one
932
+ # blank line apart), the roster last, and the skipped-count footer between
933
+ # them when non-zero. `painter` is applied to each block SEPARATELY (D21):
934
+ # a screen ScreenPaint cannot parse falls back to its own plain text
935
+ # without touching its neighbours; the default is the identity function,
936
+ # so a caller that never paints gets the plain screens verbatim. A
937
+ # directory whose delivered screen cannot be rendered (O3.28) never sinks
938
+ # the rest of the report.
939
+ def self.render_session(dirs:, skipped:, store_root:, tag_reader: ->(_dir) { nil },
940
+ branch_reader: ->(_dir) { nil }, note: nil, changed: nil,
941
+ now: Time.now, painter: ->(text) { text })
942
+ blocks = []
943
+ blocks << note if note && !note.to_s.empty?
944
+
945
+ if dirs.empty?
946
+ blocks << "No intents delivered in this session."
947
+ else
948
+ dirs.each do |dir|
949
+ blocks << begin
950
+ render_delivered(intent_dir: dir, tag_reader: tag_reader, branch_reader: branch_reader).chomp
951
+ rescue StandardError => e
952
+ "## #{intent_id(dir)} · could not render (#{e.message})"
953
+ end
954
+ end
955
+ end
956
+
957
+ if skipped.positive?
958
+ blocks << "#{skipped} completed intent#{skipped == 1 ? '' : 's'} skipped: no Done bookend in savepoint.md."
959
+ end
960
+
961
+ blocks << render_roster(store_root, changed: changed, now: now).chomp
962
+
963
+ "#{blocks.map { |b| painter.call(b) }.join("\n\n")}\n"
964
+ end
965
+
664
966
  # --- S8: --ansi passthrough (D2) -----------------------------------------------
665
967
  #
666
968
  # 316a owns the ANSI renderer; 317 only wires a generic DI seam so this
@@ -56,7 +56,7 @@ module ScreenPaint
56
56
  return :step if STEP_LINE_RE.match?(text)
57
57
  return :timeline if TIMELINE_RE.match?(text)
58
58
  return :count if COUNT_LINE_RE.match?(stripped)
59
- return :closer if ["None", "not recorded", "No intents in delivery."].include?(stripped)
59
+ return :closer if ["None", "not recorded", "No intents in delivery.", "No intents delivered in this session."].include?(stripped)
60
60
  :unknown
61
61
  end
62
62
 
@@ -101,7 +101,10 @@ module ScreenPaint
101
101
  lines = text.to_s.lines
102
102
  first_idx = lines.index { |l| !l.strip.empty? }
103
103
  return nil if first_idx.nil?
104
- return nil unless classify(lines[first_idx]) == :opener
104
+ # Intent 330 (D7/O3.26): a screen that is nothing but a single known
105
+ # closer line (e.g. "No intents delivered in this session.") has no
106
+ # opener to require - it is already the whole, honest message.
107
+ return nil unless %i[opener closer].include?(classify(lines[first_idx]))
105
108
 
106
109
  out = +""
107
110
  table = []
@@ -29,6 +29,10 @@ module SessionLedger
29
29
 
30
30
  SESSIONS_DIR = ".sessions"
31
31
  TMP_DIR = ".tmp"
32
+ # Named here, not in a CLI, so a caller can reference the key without the
33
+ # literal harness-branded env var name appearing in its own source (a few
34
+ # scripts are guarded against naming a harness at all).
35
+ SESSION_ID_ENV_KEY = "CLAUDE_CODE_SESSION_ID"
32
36
  DAY_ID = /\A\d{8}\z/
33
37
  EVENTS = %w[Item Done Note]
34
38
  STATES = { pending: "~", open: " ", done: "x", moved: ">", dropped: "-", promoted: "^" }.freeze
@@ -2,15 +2,19 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # report-screen - the three delivery-report screens (intent 317): mid-delivery
6
- # state, post-delivery delivered, and delay. Each fills from the record via
7
- # scripts/lib/report_screen.rb; no number here is written by eye.
5
+ # report-screen - the four delivery-report screens: mid-delivery state,
6
+ # post-delivery delivered, and delay (intent 317), plus session, the whole of
7
+ # one session's delivered work followed by the roster (intent 330). Each fills
8
+ # from the record via scripts/lib/report_screen.rb; no number here is written
9
+ # by eye.
8
10
  #
9
11
  # Usage:
10
12
  # report-screen state <intent_dir> [--changed "<text>"] [--ansi]
11
13
  # report-screen state --all <store_root> [--changed "<text>"] [--ansi]
12
14
  # report-screen delivered <intent_dir> [--ansi] [--repo <dir>]
13
15
  # report-screen delay <intent_dir> [--ansi]
16
+ # report-screen session <tier_root> [--session <id>] [--since <iso>]
17
+ # [--ledger-root <dir>] [--ansi]
14
18
  #
15
19
  # --ansi delegates to ScreenPaint (intent 317a, D1), the parser/re-layouter
16
20
  # in the shared TUI core. Selection is by capability, never by harness:
@@ -28,6 +32,7 @@ require "shellwords"
28
32
  require_relative "lib/report_screen"
29
33
  require_relative "lib/intent_screen"
30
34
  require_relative "lib/screen_paint"
35
+ require_relative "lib/session_ledger"
31
36
 
32
37
  def usage_abort(message)
33
38
  warn "report-screen: #{message}"
@@ -39,10 +44,13 @@ end
39
44
  # on the delivered screen is the release that merge shipped in. The
40
45
  # repository comes from --repo, else from projects.yml beside the store (the
41
46
  # installed layout, <home>/projects/<slug>/store/<id>), else the repository
42
- # this script lives in (the in-repo layout). No merge sha, no repository, or
43
- # no containing tag all answer nil, and the screen says "not recorded"
44
- # rather than guessing from HEAD (D14). The pure module never reads git.
45
- def resolve_repo(intent_dir, explicit_repo, script_dir)
47
+ # this script lives in (the in-repo layout, `include_self: true`) - unless
48
+ # `include_self: false` (the branch reader, D23), in which case a global
49
+ # intent's repo never falls back to the script's own repository, since that
50
+ # is not the intent's repository at all. No merge sha, no repository, or no
51
+ # containing tag all answer nil, and the screen says "not recorded" rather
52
+ # than guessing from HEAD (D14). The pure module never reads git.
53
+ def resolve_repo(intent_dir, explicit_repo, script_dir, include_self: true)
46
54
  candidates = []
47
55
  candidates << File.expand_path(explicit_repo) if explicit_repo
48
56
  if (m = intent_dir.match(%r{\A(.*)/projects/([^/]+)/store/[^/]+\z}))
@@ -59,7 +67,7 @@ def resolve_repo(intent_dir, explicit_repo, script_dir)
59
67
  candidates << File.expand_path(path) unless path.empty?
60
68
  end
61
69
  end
62
- candidates << File.expand_path("..", script_dir)
70
+ candidates << File.expand_path("..", script_dir) if include_self
63
71
  candidates.find { |c| File.exist?(File.join(c, ".git")) }
64
72
  end
65
73
 
@@ -78,12 +86,39 @@ def git_tag_reader(explicit_repo:, script_dir:)
78
86
  end
79
87
  end
80
88
 
89
+ # D9/D23: the injected branch reader. flow_base (a pure YAML read, never
90
+ # git) wins when the intent's project names a flow base; otherwise the
91
+ # repository's remote HEAD, else whichever of main/master exists. Resolves
92
+ # the repo with `include_self: false` (D23): a global-store intent must
93
+ # never fall back to the script's OWN repository (~/.plastic itself, on
94
+ # "main" in the installed layout) and print a confident, wrong branch.
95
+ def git_branch_reader(explicit_repo:, script_dir:)
96
+ lambda do |intent_dir|
97
+ flow = ReportScreen.flow_base(intent_dir)
98
+ next flow if flow && !flow.to_s.empty?
99
+
100
+ repo = resolve_repo(intent_dir, explicit_repo, script_dir, include_self: false)
101
+ next nil unless repo
102
+
103
+ branch = `git -C #{repo.shellescape} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`.strip
104
+ branch = branch.sub(%r{\Aorigin/}, "")
105
+ next branch unless branch.empty?
106
+
107
+ %w[main master].find do |b|
108
+ system("git", "-C", repo, "rev-parse", "--verify", "--quiet", b, out: File::NULL, err: File::NULL)
109
+ end
110
+ end
111
+ end
112
+
81
113
  args = ARGV.dup
82
114
  verb = args.shift
83
115
  changed = nil
84
116
  ansi = false
85
117
  template_path = nil
86
118
  repo_flag = nil
119
+ session_flag = nil
120
+ since_flag = nil
121
+ ledger_root_flag = nil
87
122
  positional = []
88
123
 
89
124
  while (arg = args.shift)
@@ -99,13 +134,19 @@ while (arg = args.shift)
99
134
  repo_flag = args.shift or usage_abort("--repo needs a path")
100
135
  when "--template"
101
136
  template_path = args.shift or usage_abort("--template needs a path")
137
+ when "--session"
138
+ session_flag = args.shift or usage_abort("--session needs a value")
139
+ when "--since"
140
+ since_flag = args.shift or usage_abort("--since needs a value")
141
+ when "--ledger-root"
142
+ ledger_root_flag = args.shift or usage_abort("--ledger-root needs a path")
102
143
  else
103
144
  usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--") && arg != "--all"
104
145
  positional << arg
105
146
  end
106
147
  end
107
148
 
108
- usage_abort("usage: report-screen state|delivered|delay <intent_dir> [--changed \"<text>\"] [--ansi]") unless verb
149
+ usage_abort("usage: report-screen state|delivered|delay|session <intent_dir> [--changed \"<text>\"] [--ansi]") unless verb
109
150
 
110
151
  all_mode = positional.delete("--all") ? true : false
111
152
  target = positional.first
@@ -142,7 +183,8 @@ when "delivered"
142
183
  intent_dir = File.expand_path(target)
143
184
  usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
144
185
  out = ReportScreen.render_delivered(intent_dir: intent_dir,
145
- tag_reader: git_tag_reader(explicit_repo: repo_flag, script_dir: __dir__))
186
+ tag_reader: git_tag_reader(explicit_repo: repo_flag, script_dir: __dir__),
187
+ branch_reader: git_branch_reader(explicit_repo: repo_flag, script_dir: __dir__))
146
188
  $stdout.write paint(out, ansi_enabled)
147
189
  when "delay"
148
190
  usage_abort("usage: report-screen delay <intent_dir>") unless target
@@ -150,8 +192,49 @@ when "delay"
150
192
  usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
151
193
  out = ReportScreen.render_delay(intent_dir: intent_dir)
152
194
  $stdout.write paint(out, ansi_enabled)
195
+ when "session"
196
+ usage_abort("usage: report-screen session <tier_root> [--session <id>] [--since <iso>] [--ledger-root <dir>] [--ansi]") unless target
197
+ store_root = File.expand_path(target)
198
+ usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
199
+
200
+ now = Time.now
201
+ session_id = session_flag || ENV[SessionLedger::SESSION_ID_ENV_KEY]
202
+ ledger_root = ledger_root_flag ? File.expand_path(ledger_root_flag) : ReportScreen.default_ledger_root(store_root)
203
+
204
+ if since_flag
205
+ begin
206
+ Time.parse(since_flag)
207
+ rescue ArgumentError
208
+ usage_abort("--since needs an ISO timestamp, got #{since_flag.inspect}")
209
+ end
210
+ end
211
+
212
+ dirs, skipped = ReportScreen.session_delivered_dirs(ledger_root: ledger_root, tier_root: store_root,
213
+ session: session_id, since: since_flag, now: now)
214
+ # D17: the widened window is announced whether the id was missing or simply
215
+ # matched nothing. A resumed background job carries a session id that is not
216
+ # the one on the ledger lines, and that case must not answer silently. An
217
+ # explicit --since is the caller naming the window, so it needs no note.
218
+ note =
219
+ if since_flag
220
+ nil
221
+ elsif session_id.nil? || session_id.to_s.strip.empty?
222
+ ReportScreen.window_note(ReportScreen.fallback_day(ledger_root, SessionLedger.day_id(now)),
223
+ "no session id given")
224
+ elsif !ReportScreen.session_tagged?(ledger_root: ledger_root, session: session_id, now: now)
225
+ ReportScreen.window_note(ReportScreen.fallback_day(ledger_root, SessionLedger.day_id(now)),
226
+ "this session has no line in the day ledger")
227
+ end
228
+
229
+ out = ReportScreen.render_session(
230
+ dirs: dirs, skipped: skipped, store_root: store_root,
231
+ tag_reader: git_tag_reader(explicit_repo: nil, script_dir: __dir__),
232
+ branch_reader: git_branch_reader(explicit_repo: nil, script_dir: __dir__),
233
+ note: note, now: now, painter: ->(text) { paint(text, ansi_enabled) }
234
+ )
235
+ $stdout.write out
153
236
  else
154
- usage_abort("unknown verb #{verb.inspect} (use state|delivered|delay)")
237
+ usage_abort("unknown verb #{verb.inspect} (use state|delivered|delay|session)")
155
238
  end
156
239
 
157
240
  exit 0
@@ -272,13 +272,14 @@ Read `../plastic-conventions/references/completion-and-done.md` for what "intent
272
272
  --session "$CLAUDE_CODE_SESSION_ID" \
273
273
  --index-note "<what shipped>; <suite result>"
274
274
  ```
275
- Exit 4 means a live foreign session holds the lock; 5 means the worktree is dirty (commit
276
- first, or pass `--discard-worktree-changes` deliberately); 3 means the lock survived the
277
- disarm (`/plastic-doctor check the lock status`); 6 means the structure check refused. Never
278
- leave an orphaned worktree; run `git worktree prune` on a stale reference.
279
- 6. Print `ruby ~/.plastic/scripts/report-screen delivered <intent_dir>` once (D15): this is the
280
- owner report at End, replacing the old prose Done briefing. Print it as the first thing in
281
- the reply, nothing before it and no code fence, or the hook cannot paint it.
275
+ Exit 4: a live foreign session holds the lock. 5: the worktree is dirty (commit first, or
276
+ pass `--discard-worktree-changes` deliberately). 3: the lock survived the disarm
277
+ (`/plastic-doctor check the lock status`). 6: the structure check refused. Never leave an
278
+ orphaned worktree; run `git worktree prune` on a stale reference.
279
+ 6. Print `ruby ~/.plastic/scripts/report-screen delivered <intent_dir>` once (D15): the owner
280
+ report at End, replacing the old prose Done briefing. A mid-batch status ask instead runs
281
+ `report-screen session <tier_root> --session "$CLAUDE_CODE_SESSION_ID"` (intent 330). Print
282
+ it as the first thing in the reply, no code fence, or the hook cannot paint it.
282
283
 
283
284
  ## Error Handling
284
285
 
@@ -14,6 +14,10 @@ written by eye:
14
14
  Asked, Delivered (with a Proven-by column), Evidence, Needs you.
15
15
  - **`report-screen delay <intent_dir>`** - printed only on request ("why did X take so long"):
16
16
  the delivery as a timeline plus the derived `Where the time went` line.
17
+ - **`report-screen session <tier_root>`** - the answer to an UNNAMED status ask ("where are we
18
+ with delivery", "what is the status"): one `delivered` screen per intent this session
19
+ completed, oldest first, then the `state --all` roster. Intent 330's ruling: a status ask
20
+ answers with what actually shipped, not the in-flight roster alone.
17
21
 
18
22
  ## The five triggers for `state`
19
23
 
@@ -111,11 +111,15 @@ For a live intent's directory:
111
111
  `ruby ~/.plastic/scripts/report-screen state <intent_dir>` and print its output as it is:
112
112
  the title, the field table, the `Changed` row, and the Steps table come from the record,
113
113
  never by eye. For "where are we" with no intent named, run
114
- `ruby ~/.plastic/scripts/report-screen state --all <store_root>` for the roster across every
115
- in-delivery intent. Route "why did X take so long" to
116
- `ruby ~/.plastic/scripts/report-screen delay <intent_dir>` instead - every verb prints the
117
- same plain screen on any harness, painted only where the harness supports it, with no
118
- branching on harness name. Under the `state` screen
114
+ `ruby ~/.plastic/scripts/report-screen session <tier_root> --session <this session's id>`
115
+ (intent 330; pass the id your harness gives you, or the screen widens to the whole day and
116
+ says so): it prints one
117
+ `delivered` screen per intent this session actually completed, oldest first, then the same
118
+ roster `report-screen state --all <store_root>` prints on its own - `state --all` stays the
119
+ right call when only the in-flight roster is wanted, with nothing delivered above it. Route
120
+ "why did X take so long" to `ruby ~/.plastic/scripts/report-screen delay <intent_dir>`
121
+ instead - every verb prints the same plain screen on any harness, painted only where the
122
+ harness supports it, with no branching on harness name. Under the `state` screen
119
123
  write **What this means** as two to four bullets in plain words (what the intent is for,
120
124
  what has landed, what is left, any defect named by step), then close with **needs input:**
121
125
  naming the first open step. Then continue the work in the