@zalom/plastic 2.0.0-alpha.13 → 2.0.0-alpha.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/message-display +55 -2
- package/package.json +1 -1
- package/scripts/dashboard.rb +238 -8
- package/scripts/doctor.rb +291 -4
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/doctor_core.rb +97 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/installer_core.rb +23 -3
- package/scripts/lib/message_display.rb +267 -47
- package/scripts/lib/report_screen.rb +837 -18
- package/scripts/lib/roadmap_queue.rb +19 -2
- package/scripts/lib/roadmap_savepoint.rb +36 -7
- package/scripts/lib/savepoint.rb +12 -0
- package/scripts/lib/screen_paint.rb +240 -11
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/report-screen +41 -7
- package/scripts/savepoint-note +11 -9
- package/skills/auto/SKILL.md +9 -9
- package/skills/auto/references/human-report-contract.md +79 -8
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +19 -21
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-ending/SKILL.md +6 -4
- package/skills/intent-executing/SKILL.md +2 -0
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/roadmap/SKILL.md +9 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/intent-screen.md +1 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +1 -1
package/hooks/message-display
CHANGED
|
@@ -57,8 +57,18 @@ if [ "$is_index_zero" = 1 ]; then
|
|
|
57
57
|
*'"delta":"#'*|*'"delta": "#'*) handoff=1 ;;
|
|
58
58
|
*'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
|
|
59
59
|
*'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
esac
|
|
61
|
+
# 331a review fix (M12e): chunk 0 is not exempt from D5 either. The
|
|
62
|
+
# intent's own flagship shape -- a lead-in sentence and the opener in the
|
|
63
|
+
# SAME first chunk -- never starts with "#", "▶", or "✔", so the arm
|
|
64
|
+
# above alone never sees it. Joins it with the identical opener-anywhere
|
|
65
|
+
# globs used for a later chunk (M12a) rather than replacing anything:
|
|
66
|
+
# same reasoning as the comment at that site applies verbatim here.
|
|
67
|
+
case $INPUT in
|
|
68
|
+
*'"delta":"'*'▶'*|*'"delta": "'*'▶'*) handoff=1 ;;
|
|
69
|
+
*'"delta":"'*'✔'*|*'"delta": "'*'✔'*) handoff=1 ;;
|
|
70
|
+
*'"delta":"'*'\u25b6'*|*'"delta": "'*'\u25b6'*) handoff=1 ;;
|
|
71
|
+
*'"delta":"'*'\u2714'*|*'"delta": "'*'\u2714'*) handoff=1 ;;
|
|
62
72
|
esac
|
|
63
73
|
else
|
|
64
74
|
# A later chunk: hand off when this message's directory already exists
|
|
@@ -74,8 +84,51 @@ else
|
|
|
74
84
|
*'"delta":""'*|*'"delta": ""'*) handoff=1 ;;
|
|
75
85
|
*'"delta":"\n"'*|*'"delta": "\n"'*) handoff=1 ;;
|
|
76
86
|
esac
|
|
87
|
+
# 331a (D5): the hook engages anywhere. An opener can now fall ANYWHERE
|
|
88
|
+
# inside a later chunk's own delta, not only at its start (the shape
|
|
89
|
+
# tests just above), so these globs search the delta value rather than
|
|
90
|
+
# anchor to its opening quote. Still anchored to the "delta":" key itself
|
|
91
|
+
# (never dropped, matrix M12d) so an ordinary field elsewhere in the
|
|
92
|
+
# payload can never be mistaken for the delta's own text; within that,
|
|
93
|
+
# every shipped opener (## ▶, ## ✔, ▶ In delivery, ✔ + digit) contains a
|
|
94
|
+
# bare ▶ or ✔, so matching the bare glyph anywhere covers all four at
|
|
95
|
+
# once. Prose that merely shapes like an opener still hands off this way
|
|
96
|
+
# (an accepted over-match: Ruby's own grammar is the final, stricter
|
|
97
|
+
# arbiter and fails open on anything that is not a real one). Raw glyph
|
|
98
|
+
# and \u-escaped forms both count, since the encoder's choice is not ours
|
|
99
|
+
# to predict.
|
|
100
|
+
case $INPUT in
|
|
101
|
+
*'"delta":"'*'▶'*|*'"delta": "'*'▶'*) handoff=1 ;;
|
|
102
|
+
*'"delta":"'*'✔'*|*'"delta": "'*'✔'*) handoff=1 ;;
|
|
103
|
+
*'"delta":"'*'\u25b6'*|*'"delta": "'*'\u25b6'*) handoff=1 ;;
|
|
104
|
+
*'"delta":"'*'\u2714'*|*'"delta": "'*'\u2714'*) handoff=1 ;;
|
|
105
|
+
esac
|
|
77
106
|
fi
|
|
78
107
|
|
|
79
108
|
[ "$handoff" = 1 ] || exit 0
|
|
80
109
|
|
|
110
|
+
# 331a1 (D1): chunk 0 is about to hand off to Ruby, which takes on the
|
|
111
|
+
# order of 150 ms to boot before it ever writes SCREEN or NOSCREEN. Claude
|
|
112
|
+
# Code fires the per-chunk hook PROCESSES CONCURRENTLY (see the header
|
|
113
|
+
# comment above), so a later chunk landing inside that window used to find
|
|
114
|
+
# no message directory at all, fail this script's own cheap shape test,
|
|
115
|
+
# and pass through as raw Markdown - measured at 15 to 21 chunks of every
|
|
116
|
+
# screen, against a stream-realistic 5 ms stagger. Staking a PENDING
|
|
117
|
+
# marker here, the instant the handoff gate above passes, closes that
|
|
118
|
+
# window down to microseconds: a later chunk's own "does the directory
|
|
119
|
+
# exist" check (below, unchanged) now finds it almost at once, and
|
|
120
|
+
# MessageDisplay polls for the real decision instead of judging the
|
|
121
|
+
# chunk's own shape. mkdir is the one non-builtin on this whole path - it
|
|
122
|
+
# forks, unlike everything else in this file - but it runs ONLY here,
|
|
123
|
+
# after the handoff gate, so the ordinary (non-candidate) chunk that is
|
|
124
|
+
# the overwhelming common case still forks nothing (matrix L8). A failed
|
|
125
|
+
# mkdir or printf (an unwritable tmp root, a race with another process) is
|
|
126
|
+
# silently swallowed: it must never change this script's own exit status
|
|
127
|
+
# or skip the Ruby handoff below - Ruby's own polling already fails open
|
|
128
|
+
# (D5) when no decision ever appears.
|
|
129
|
+
if [ "$is_index_zero" = 1 ]; then
|
|
130
|
+
mkdir -p "$MSGDIR" 2>/dev/null
|
|
131
|
+
printf '' > "$MSGDIR/PENDING" 2>/dev/null
|
|
132
|
+
fi
|
|
133
|
+
|
|
81
134
|
printf '%s' "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-message-display"
|
package/package.json
CHANGED
package/scripts/dashboard.rb
CHANGED
|
@@ -27,6 +27,18 @@ require "date"
|
|
|
27
27
|
require_relative "doctor"
|
|
28
28
|
require_relative "lib/savepoint"
|
|
29
29
|
require_relative "lib/lock"
|
|
30
|
+
require_relative "lib/dashboard_screen"
|
|
31
|
+
require_relative "lib/intent_screen"
|
|
32
|
+
require_relative "lib/report_screen"
|
|
33
|
+
require_relative "lib/roadmap_queue"
|
|
34
|
+
require_relative "lib/day_summary"
|
|
35
|
+
require_relative "lib/screen_paint"
|
|
36
|
+
|
|
37
|
+
# Intent 331a (D6/R8): every caller-added screen kind file registers itself on
|
|
38
|
+
# load, so this glob is the only wiring a new kind needs (scripts/report-screen:42
|
|
39
|
+
# does the identical glob for the same reason). Sorted for a deterministic load
|
|
40
|
+
# order; tolerates the directory being absent or empty.
|
|
41
|
+
Dir.glob(File.join(__dir__, "lib", "screens", "*.rb")).sort.each { |f| require_relative f }
|
|
30
42
|
|
|
31
43
|
PLASTIC_HOME = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
32
44
|
|
|
@@ -670,15 +682,10 @@ end
|
|
|
670
682
|
# recent_delivery_summary (D1 fix) uses this twice: once per intent label, so a
|
|
671
683
|
# paragraph-long `intent` field collapses to a short name, and once on the fully assembled
|
|
672
684
|
# summary string, the hard budget cap that holds no matter how the per-label math adds up.
|
|
685
|
+
# Intent 331f: the one implementation now lives on ReportScreen; this delegates so dashboard.rb
|
|
686
|
+
# and every ReportScreen render entry point share it.
|
|
673
687
|
def truncate_on_word_boundary(text, max_chars)
|
|
674
|
-
|
|
675
|
-
return t if t.length <= max_chars
|
|
676
|
-
ellipsis = "…"
|
|
677
|
-
limit = [max_chars - ellipsis.length, 0].max
|
|
678
|
-
slice = t[0, limit]
|
|
679
|
-
cut = slice.rindex(/\s/)
|
|
680
|
-
slice = slice[0, cut] if cut && cut.positive?
|
|
681
|
-
"#{slice.rstrip}#{ellipsis}"
|
|
688
|
+
ReportScreen.truncate_on_word_boundary(text, max_chars)
|
|
682
689
|
end
|
|
683
690
|
|
|
684
691
|
# D3 fix (intent 202 gate review): completion dates only carry day granularity, and many
|
|
@@ -969,6 +976,211 @@ def render_json(records, scope_label)
|
|
|
969
976
|
}
|
|
970
977
|
end
|
|
971
978
|
|
|
979
|
+
# ---------------------------------------------------------------------------
|
|
980
|
+
# Screen renderer (intent 331d) - dashboard.rb continue|project <slug> --screen.
|
|
981
|
+
#
|
|
982
|
+
# Sources every fact through the same helpers report-screen and DaySummary
|
|
983
|
+
# already use (Resolved contract, ACTION_1): a missing source prints "not
|
|
984
|
+
# recorded" or "none", never a guess or a crash (R1). The classification
|
|
985
|
+
# pipeline above (classify, rank_key, actionable?, QUADRANTS, disposition_of)
|
|
986
|
+
# is read here, never edited: the screen is a new renderer over the same
|
|
987
|
+
# records (D3), so every count and rank matches what --json already reports
|
|
988
|
+
# for the identical scope.
|
|
989
|
+
# ---------------------------------------------------------------------------
|
|
990
|
+
|
|
991
|
+
SCREEN_ACTIVE_CAP = 8
|
|
992
|
+
SCREEN_NEXT_CAP = 6
|
|
993
|
+
SCREEN_NOT_RECORDED = "not recorded"
|
|
994
|
+
|
|
995
|
+
def screen_scope_slug(scope)
|
|
996
|
+
scope.sub(/\Aproject:/, "")
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
# Tier root: PLASTIC_HOME for "global", PLASTIC_HOME/projects/<slug> for
|
|
1000
|
+
# "project:<slug>" (Resolved contract) - the same tier scripts/lib/qmd_sync.rb
|
|
1001
|
+
# already derives from a store path. Global's own roadmaps/INDEX.md sit
|
|
1002
|
+
# directly under PLASTIC_HOME, with no intervening "store" segment.
|
|
1003
|
+
def screen_tier_root(plastic_home, scope)
|
|
1004
|
+
return plastic_home if scope == "global"
|
|
1005
|
+
File.join(plastic_home, "projects", screen_scope_slug(scope))
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
# "global" spans every store (the same aggregate render_continue and --json's
|
|
1009
|
+
# continue-mode subset already read); a project scope narrows to its own
|
|
1010
|
+
# records. Never a project-only helper on the unscoped path (D10).
|
|
1011
|
+
def screen_scoped_records(records, scope)
|
|
1012
|
+
return records if scope == "global"
|
|
1013
|
+
records.select { |r| r[:scope] == scope }
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def screen_active_count(scoped)
|
|
1017
|
+
scoped.count { |r| r[:status] == "active" }
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
# D2: a stale or absent lock never counts as delivering.
|
|
1021
|
+
def screen_in_delivery_count(scoped, now:)
|
|
1022
|
+
scoped.count do |r|
|
|
1023
|
+
r[:status] == "active" && Lock.who(r[:intent_dir], now: now)["state"] == "fresh"
|
|
1024
|
+
end
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# D3: completed_on (the INDEX.md completion date) is the source of truth,
|
|
1028
|
+
# rec[:done_at] (the savepoint's own Done timestamp) the fallback when a
|
|
1029
|
+
# completed intent has no dated INDEX entry yet. `created` is never read.
|
|
1030
|
+
def screen_completion_date(rec)
|
|
1031
|
+
raw = rec[:completed_on].to_s
|
|
1032
|
+
raw = rec[:done_at].to_s if raw.empty?
|
|
1033
|
+
return nil if raw.empty?
|
|
1034
|
+
begin
|
|
1035
|
+
Date.parse(raw)
|
|
1036
|
+
rescue ArgumentError, TypeError
|
|
1037
|
+
nil
|
|
1038
|
+
end
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
def screen_delivered_count(scoped, now:)
|
|
1042
|
+
today_date = now.to_date
|
|
1043
|
+
scoped.count do |r|
|
|
1044
|
+
next false unless r[:status] == "completed"
|
|
1045
|
+
date = screen_completion_date(r)
|
|
1046
|
+
next false unless date
|
|
1047
|
+
diff = (today_date - date).to_i
|
|
1048
|
+
diff >= 0 && diff <= 7
|
|
1049
|
+
end
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
# "none" on state none/tie/exhausted or a missing roadmaps dir (R1, D4); the
|
|
1053
|
+
# live frontier otherwise (D17). RoadmapQueue itself tolerates an absent
|
|
1054
|
+
# directory (Dir.exist? guard), so this never crashes on a bare tier.
|
|
1055
|
+
def screen_roadmap_field(plastic_home, scope, now:)
|
|
1056
|
+
tier = screen_tier_root(plastic_home, scope)
|
|
1057
|
+
reader = RoadmapQueue.new(roadmaps_dir: File.join(tier, "roadmaps"),
|
|
1058
|
+
index_path: File.join(tier, "INDEX.md"), now: now)
|
|
1059
|
+
payload = reader.which
|
|
1060
|
+
return "none" if %w[none tie exhausted].include?(payload["state"])
|
|
1061
|
+
"#{payload['roadmap']} · #{payload['frontier_wave']}"
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
# Sessions are global, never per-project (Resolved contract): always the
|
|
1065
|
+
# PLASTIC_HOME/store tmp root, and `session: nil` (A6) so the calling
|
|
1066
|
+
# session's own live heartbeat counts rather than being excluded as "self".
|
|
1067
|
+
def screen_sessions_count(plastic_home, now:)
|
|
1068
|
+
store = File.join(plastic_home, "store")
|
|
1069
|
+
DaySummary.active_sessions(store, nil, now: now, ttl: DaySummary::HEARTBEAT_TTL).size
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
def screen_changed_field(scoped)
|
|
1073
|
+
latest = scoped.map { |r| r[:last_accessed_at].to_s }.reject(&:empty?).max
|
|
1074
|
+
return SCREEN_NOT_RECORDED unless latest
|
|
1075
|
+
t = begin
|
|
1076
|
+
Time.parse(latest)
|
|
1077
|
+
rescue ArgumentError, TypeError
|
|
1078
|
+
nil
|
|
1079
|
+
end
|
|
1080
|
+
return SCREEN_NOT_RECORDED unless t
|
|
1081
|
+
t.utc.strftime("%Y-%m-%d %H:%M UTC")
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# D6, intent 331f: the one Lead freshness rule every screen shares - fresh prints
|
|
1085
|
+
# "agent · key", a stale lock prints "stale · N min" (never "not recorded" or a bare "idle",
|
|
1086
|
+
# which would either hide the staleness or contradict a live In-delivery count with a dead
|
|
1087
|
+
# lead), and no lock (or one that will not read) prints "idle".
|
|
1088
|
+
def screen_lead_field(rec, now:)
|
|
1089
|
+
ReportScreen.lead_cell(rec[:intent_dir], now: now)
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
# D3 (331d1): the owner ruled no rendered row exceeds 115 visible columns.
|
|
1093
|
+
# The bound is on the WHOLE pipe-delimited row, not on one cell, so the
|
|
1094
|
+
# Intent cell gets whatever the other cells leave it. A cell short enough on
|
|
1095
|
+
# its own still drifts the row past the bound once the bar, the lead and the
|
|
1096
|
+
# scaffolding are added, which is the failure this measures away.
|
|
1097
|
+
SCREEN_ROW_MAX_COLUMNS = 115
|
|
1098
|
+
|
|
1099
|
+
# D2 (331d1): the Intent cell carries the intent line up to but not including
|
|
1100
|
+
# its first colon. A Plastic intent line opens with a short name and then
|
|
1101
|
+
# explains itself after a colon, so the lead IS the name; a line with no
|
|
1102
|
+
# colon is already a name and passes through whole. Escaping happens here, so
|
|
1103
|
+
# the budget below measures what actually reaches the row.
|
|
1104
|
+
def screen_intent_title(rec)
|
|
1105
|
+
# D8: one colon rule for every screen, on ReportScreen. `max:` is the whole line here
|
|
1106
|
+
# because screen_fit_intent does this cell's own width budgeting a moment later.
|
|
1107
|
+
cell(ReportScreen.title_before_colon(rec[:intent], max: rec[:intent].to_s.length + 1))
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
# The Intent cell fitted to what the row has left. `others` are the already
|
|
1111
|
+
# rendered sibling cells; the scaffolding is the leading "| ", a " | " between
|
|
1112
|
+
# every pair of cells, and the trailing " |". Intent 331f1: the one implementation now lives
|
|
1113
|
+
# on ReportScreen (fit_row_cell), measured in DISPLAY columns rather than String#length - an
|
|
1114
|
+
# `others` cell carrying a progress bar costs two columns per glyph, not one (RC1) - so
|
|
1115
|
+
# roadmap_state_entries_table's own Intent cell spends the same budget by the same rule.
|
|
1116
|
+
def screen_fit_intent(title, others)
|
|
1117
|
+
ReportScreen.fit_row_cell(title, others, max: SCREEN_ROW_MAX_COLUMNS)
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
# D6: last_accessed_at descending, then id, capped at SCREEN_ACTIVE_CAP.
|
|
1121
|
+
def screen_where_we_are(scoped, now:)
|
|
1122
|
+
active = scoped.select { |r| r[:status] == "active" }
|
|
1123
|
+
ordered = active.sort_by { |r| [invert_ts(r[:last_accessed_at]), r[:id]] }
|
|
1124
|
+
ordered.first(SCREEN_ACTIVE_CAP).map do |r|
|
|
1125
|
+
items = IntentScreen.checklist_items(r[:intent_dir])
|
|
1126
|
+
progress = IntentScreen.progress_fields(items)
|
|
1127
|
+
graph_id = r[:id].to_s
|
|
1128
|
+
stage = r[:lifecycle].to_s.capitalize
|
|
1129
|
+
bar = "#{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']}"
|
|
1130
|
+
lead = screen_lead_field(r, now: now)
|
|
1131
|
+
{
|
|
1132
|
+
graph_id: graph_id,
|
|
1133
|
+
intent: screen_fit_intent(screen_intent_title(r), [graph_id, stage, bar, lead]),
|
|
1134
|
+
stage: stage,
|
|
1135
|
+
progress: bar,
|
|
1136
|
+
lead: lead,
|
|
1137
|
+
}
|
|
1138
|
+
end
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
# A2: the exact pool render_json's dispatchable_queue ranks - actionable?
|
|
1142
|
+
# records, rank_key order, filtered to disposition defer/research (D12
|
|
1143
|
+
# excludes drive/triage). Rank is that queue's own 1-based position, so a
|
|
1144
|
+
# capped display row's rank always agrees with the uncapped --json contract.
|
|
1145
|
+
def screen_dispatchable_pool(scope_records)
|
|
1146
|
+
ranked = scope_records.select { |r| actionable?(r) }.sort_by { |r| rank_key(r) }
|
|
1147
|
+
ranked.select { |r| %w[defer research].include?(r[:disposition]) }
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
def screen_where_we_go_next(scope_records)
|
|
1151
|
+
pool = screen_dispatchable_pool(scope_records)
|
|
1152
|
+
pool.each_with_index.map do |r, i|
|
|
1153
|
+
rank = i + 1
|
|
1154
|
+
graph_id = r[:id].to_s
|
|
1155
|
+
reason = r[:quadrant].to_s
|
|
1156
|
+
{
|
|
1157
|
+
rank: rank,
|
|
1158
|
+
graph_id: graph_id,
|
|
1159
|
+
intent: screen_fit_intent(screen_intent_title(r), [rank.to_s, graph_id, reason]),
|
|
1160
|
+
reason: reason,
|
|
1161
|
+
}
|
|
1162
|
+
end.first(SCREEN_NEXT_CAP)
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
def screen_fields(records, scope, plastic_home:, now: Time.now)
|
|
1166
|
+
scoped = screen_scoped_records(records, scope)
|
|
1167
|
+
{
|
|
1168
|
+
scope: scope,
|
|
1169
|
+
active: screen_active_count(scoped),
|
|
1170
|
+
in_delivery: screen_in_delivery_count(scoped, now: now),
|
|
1171
|
+
delivered: screen_delivered_count(scoped, now: now),
|
|
1172
|
+
roadmap: screen_roadmap_field(plastic_home, scope, now: now),
|
|
1173
|
+
sessions: screen_sessions_count(plastic_home, now: now),
|
|
1174
|
+
changed: screen_changed_field(scoped),
|
|
1175
|
+
where_we_are: screen_where_we_are(scoped, now: now),
|
|
1176
|
+
where_we_go_next: screen_where_we_go_next(scoped),
|
|
1177
|
+
}
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
def render_screen(records, scope, plastic_home: PLASTIC_HOME, now: Time.now)
|
|
1181
|
+
DashboardScreen.render(screen_fields(records, scope, plastic_home: plastic_home, now: now))
|
|
1182
|
+
end
|
|
1183
|
+
|
|
972
1184
|
# ---------------------------------------------------------------------------
|
|
973
1185
|
# CLI
|
|
974
1186
|
# ---------------------------------------------------------------------------
|
|
@@ -997,6 +1209,8 @@ def main(argv)
|
|
|
997
1209
|
json = argv.delete("--json")
|
|
998
1210
|
data = argv.delete("--data")
|
|
999
1211
|
plain = argv.delete("--plain")
|
|
1212
|
+
screen = argv.delete("--screen")
|
|
1213
|
+
ansi = argv.delete("--ansi")
|
|
1000
1214
|
all = argv.delete("--all")
|
|
1001
1215
|
limit_active = extract_flag_value(argv, "--limit-active")
|
|
1002
1216
|
limit_next = extract_flag_value(argv, "--limit-next")
|
|
@@ -1047,6 +1261,22 @@ def main(argv)
|
|
|
1047
1261
|
return 0
|
|
1048
1262
|
end
|
|
1049
1263
|
|
|
1264
|
+
if screen
|
|
1265
|
+
if mode == "project" && (slug.nil? || slug.empty?)
|
|
1266
|
+
warn "usage: dashboard.rb project <slug> --screen"
|
|
1267
|
+
return 2
|
|
1268
|
+
end
|
|
1269
|
+
scope = mode == "project" ? "project:#{slug}" : "global"
|
|
1270
|
+
text = render_screen(records, scope, plastic_home: PLASTIC_HOME, now: Time.now)
|
|
1271
|
+
# A4: the identical capability guard scripts/report-screen:161 applies -
|
|
1272
|
+
# NO_COLOR always wins to plain; a non-tty stdout stays plain unless the
|
|
1273
|
+
# PLASTIC_FORCE_COLOR test seam is set.
|
|
1274
|
+
ansi_enabled = ansi && ENV["NO_COLOR"].to_s.empty? &&
|
|
1275
|
+
($stdout.tty? || ENV["PLASTIC_FORCE_COLOR"] == "1")
|
|
1276
|
+
print(ansi_enabled ? (ScreenPaint.paint(text, color: true) || text) : text)
|
|
1277
|
+
return 0
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1050
1280
|
case mode
|
|
1051
1281
|
when "continue" then print render_continue(records)
|
|
1052
1282
|
when "project"
|