@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 +1 -1
- package/scripts/lib/report_screen.rb +319 -17
- package/scripts/lib/screen_paint.rb +5 -2
- package/scripts/lib/session_ledger.rb +4 -0
- package/scripts/report-screen +94 -11
- package/skills/auto/SKILL.md +8 -7
- package/skills/auto/references/human-report-contract.md +4 -0
- package/skills/intent-continuing/SKILL.md +9 -5
package/package.json
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
413
|
+
branch = branch_reader.call(intent_dir)
|
|
335
414
|
sha_text = sha || NOT_RECORDED
|
|
336
|
-
|
|
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
|
-
|
|
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 ==
|
|
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
|
-
|
|
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
|
-
|
|
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} · #{
|
|
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
|
-
|
|
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
|
package/scripts/report-screen
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
# report-screen - the
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
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
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
6. Print `ruby ~/.plastic/scripts/report-screen delivered <intent_dir>` once (D15):
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|