@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20
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/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +886 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- 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/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +392 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +24 -17
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- 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 +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +17 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -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/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -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 +11 -0
- package/templates/roadmap.md +13 -0
package/scripts/dashboard.rb
CHANGED
|
@@ -27,6 +27,19 @@ 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
|
+
require_relative "lib/ready_set"
|
|
37
|
+
|
|
38
|
+
# Intent 331a (D6/R8): every caller-added screen kind file registers itself on
|
|
39
|
+
# load, so this glob is the only wiring a new kind needs (scripts/report-screen:42
|
|
40
|
+
# does the identical glob for the same reason). Sorted for a deterministic load
|
|
41
|
+
# order; tolerates the directory being absent or empty.
|
|
42
|
+
Dir.glob(File.join(__dir__, "lib", "screens", "*.rb")).sort.each { |f| require_relative f }
|
|
30
43
|
|
|
31
44
|
PLASTIC_HOME = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
|
|
32
45
|
|
|
@@ -56,13 +69,26 @@ rescue StandardError
|
|
|
56
69
|
nil
|
|
57
70
|
end
|
|
58
71
|
|
|
59
|
-
#
|
|
72
|
+
# The link form's id: "- [id](path)...". The class excludes "[" (as well as
|
|
73
|
+
# "]" and whitespace) so this can never match a wikilink line's OWN opening
|
|
74
|
+
# bracket and key it as "[71" (row F, spec 315b): "- [[71]] ..." fails this
|
|
75
|
+
# pattern outright (the character right after "- [" is itself "[", which the
|
|
76
|
+
# class disallows), leaving it to WIKILINK_ID_RE below.
|
|
77
|
+
LINK_ID_RE = /^- \[([^\[\]\s]+)/
|
|
78
|
+
|
|
79
|
+
# The wikilink form's id: "- [[id]] name (em dash) description (date; ...)",
|
|
80
|
+
# the shape mihradesign's INDEX.md and others use. Bare id, no brackets.
|
|
81
|
+
WIKILINK_ID_RE = /^- \[\[([^\[\]]+)\]\]/
|
|
82
|
+
|
|
83
|
+
# Returns the set of intent ids listed under a given INDEX.md section, link
|
|
84
|
+
# form and wikilink form alike (row F5: Active, Abandoned, Completed, and a
|
|
85
|
+
# store's Future section can all mix both forms).
|
|
60
86
|
def index_section_ids(index_path, header)
|
|
61
87
|
return [] unless File.exist?(index_path)
|
|
62
88
|
body = File.read(index_path)
|
|
63
89
|
seg = body[/^#{Regexp.escape(header)}\s*\n(.*?)(?=^## |\z)/m, 1]
|
|
64
90
|
return [] unless seg
|
|
65
|
-
seg.scan(
|
|
91
|
+
seg.scan(LINK_ID_RE).flatten + seg.scan(WIKILINK_ID_RE).flatten
|
|
66
92
|
end
|
|
67
93
|
|
|
68
94
|
# Bug found at intent 202's gate review: the date used to be anchored to the END of the
|
|
@@ -77,21 +103,37 @@ end
|
|
|
77
103
|
# (end-intent's own Bridge.index_entry_match accepts either on read). Because regex
|
|
78
104
|
# alternation is leftmost-first, this only ever matches the date immediately after the
|
|
79
105
|
# link. It never continues scanning into the note prose, so a second date mentioned
|
|
80
|
-
# later in a note's free text cannot be mistaken for the completion date.
|
|
81
|
-
|
|
106
|
+
# later in a note's free text cannot be mistaken for the completion date. The id class
|
|
107
|
+
# excludes "[" (row F) so this can never fire on a wikilink line's own opening bracket.
|
|
108
|
+
COMPLETION_DATE_RE = /^- \[([^\[\]\s]+).*?\)\s*[\u2014-]\s*(\d{4}-\d{2}-\d{2})\b/
|
|
109
|
+
|
|
110
|
+
# The wikilink form's completion date: "- [[id]] name (em dash) description
|
|
111
|
+
# (date; other notes)". Real shape (mihradesign's INDEX.md): the date is the
|
|
112
|
+
# first thing inside the entry's trailing parenthetical. A SEPARATE regex
|
|
113
|
+
# from COMPLETION_DATE_RE (row F review finding), not one four-group
|
|
114
|
+
# alternation: a four-group `scan` breaks `String#scan(...).to_h`, and a bare
|
|
115
|
+
# `\(` alternative on the link form would harvest a date out of note prose on
|
|
116
|
+
# an entry with no canonical date of its own.
|
|
117
|
+
WIKILINK_COMPLETION_DATE_RE = /^- \[\[([^\[\]]+)\]\].*?\((\d{4}-\d{2}-\d{2})\b/
|
|
82
118
|
|
|
83
119
|
# Map of intent id -> completion date string, parsed from the "## Completed" section
|
|
84
|
-
# (lines like "- [12 (em dash) title](link) (em dash) 2026-06-10 optional note text"
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
120
|
+
# (lines like "- [12 (em dash) title](link) (em dash) 2026-06-10 optional note text",
|
|
121
|
+
# or the wikilink form "- [[12]] title (em dash) description (2026-06-10; notes)").
|
|
122
|
+
# Deterministic, content-derived. Two regexes scanned separately and merged into one
|
|
123
|
+
# hash (row F), so each form's own matching rules stay independent: the wikilink
|
|
124
|
+
# alternative can never steal a date out of a link-form entry's note prose, because it
|
|
125
|
+
# never even looks at a link-form line (its "- [[" anchor cannot match a line whose
|
|
126
|
+
# second character is not itself "["). Observability: a populated "## Completed"
|
|
127
|
+
# section that yields not one single dated entry is a parser regression, not a
|
|
128
|
+
# legitimately empty result, so it is surfaced with a stderr warning rather than
|
|
129
|
+
# rotting invisibly (the same silent-failure class intent 202's gate review caught this
|
|
130
|
+
# file already committing).
|
|
89
131
|
def completion_dates(index_path)
|
|
90
132
|
return {} unless File.exist?(index_path)
|
|
91
133
|
body = File.read(index_path)
|
|
92
134
|
seg = body[/^## Completed\s*\n(.*?)(?=^## |\z)/m, 1] || ""
|
|
93
135
|
entry_count = seg.scan(/^- \[/).size
|
|
94
|
-
dates = seg.scan(COMPLETION_DATE_RE).to_h
|
|
136
|
+
dates = seg.scan(COMPLETION_DATE_RE).to_h.merge(seg.scan(WIKILINK_COMPLETION_DATE_RE).to_h)
|
|
95
137
|
if entry_count.positive? && dates.empty?
|
|
96
138
|
warn "dashboard: completion_dates parsed 0/#{entry_count} dates from the " \
|
|
97
139
|
"\"## Completed\" section of #{index_path}; treat this as a parser regression, " \
|
|
@@ -227,9 +269,27 @@ def parse_intent(store_info, dir_name, status_index)
|
|
|
227
269
|
# durable lock beside the intent. intent_line deliberately does not expose
|
|
228
270
|
# this filesystem path in --data or any rendered surface.
|
|
229
271
|
intent_dir: File.expand_path(dir),
|
|
272
|
+
# Intent 336 (G3, D14): the ready node count for a graph-shaped intent,
|
|
273
|
+
# read through ReadySet, nil for every other intent. Explicitly advisory,
|
|
274
|
+
# entirely separate from the sources-derived "unblocked" flag below,
|
|
275
|
+
# which is the cross-intent knowledge graph and stays exactly as it was
|
|
276
|
+
# (327 D40 forbids that field from gating work). ReadySet.analyze is
|
|
277
|
+
# called only when real.("graph.md") is true, so the cost of this read
|
|
278
|
+
# is zero for every intent that has no graph.
|
|
279
|
+
ready_node_count: real.("graph.md") ? ready_node_count_for(dir) : nil,
|
|
230
280
|
}
|
|
231
281
|
end
|
|
232
282
|
|
|
283
|
+
# The count of currently-ready nodes in a graph-shaped intent's own graph.md,
|
|
284
|
+
# or nil when ReadySet cannot analyze it (a malformed or cyclic graph never
|
|
285
|
+
# raises out of the dashboard).
|
|
286
|
+
def ready_node_count_for(dir)
|
|
287
|
+
analysis = ReadySet.analyze(dir)
|
|
288
|
+
return nil unless analysis[:ok]
|
|
289
|
+
|
|
290
|
+
analysis[:nodes].count { |_, view| view[:ready] }
|
|
291
|
+
end
|
|
292
|
+
|
|
233
293
|
def checklist_partially_done?(path)
|
|
234
294
|
txt = File.read(path)
|
|
235
295
|
checked = txt.scan(/^\s*- \[x\]/i).size
|
|
@@ -641,15 +701,10 @@ end
|
|
|
641
701
|
# recent_delivery_summary (D1 fix) uses this twice: once per intent label, so a
|
|
642
702
|
# paragraph-long `intent` field collapses to a short name, and once on the fully assembled
|
|
643
703
|
# summary string, the hard budget cap that holds no matter how the per-label math adds up.
|
|
704
|
+
# Intent 331f: the one implementation now lives on ReportScreen; this delegates so dashboard.rb
|
|
705
|
+
# and every ReportScreen render entry point share it.
|
|
644
706
|
def truncate_on_word_boundary(text, max_chars)
|
|
645
|
-
|
|
646
|
-
return t if t.length <= max_chars
|
|
647
|
-
ellipsis = "…"
|
|
648
|
-
limit = [max_chars - ellipsis.length, 0].max
|
|
649
|
-
slice = t[0, limit]
|
|
650
|
-
cut = slice.rindex(/\s/)
|
|
651
|
-
slice = slice[0, cut] if cut && cut.positive?
|
|
652
|
-
"#{slice.rstrip}#{ellipsis}"
|
|
707
|
+
ReportScreen.truncate_on_word_boundary(text, max_chars)
|
|
653
708
|
end
|
|
654
709
|
|
|
655
710
|
# D3 fix (intent 202 gate review): completion dates only carry day granularity, and many
|
|
@@ -792,6 +847,7 @@ def next_work(records, cap: NEXT_WORK_CAP)
|
|
|
792
847
|
{ id: r[:id], intent: r[:intent], scope: r[:scope], lifecycle: r[:lifecycle],
|
|
793
848
|
value: r[:value].to_s, disposition: r[:disposition], flags: r[:flags],
|
|
794
849
|
what: cell(text), flags_label: cell(Array(r[:flags]).join(", ")),
|
|
850
|
+
ready_node_count: r[:ready_node_count],
|
|
795
851
|
line: "#{r[:id]} #{text}" }
|
|
796
852
|
end
|
|
797
853
|
end
|
|
@@ -940,6 +996,211 @@ def render_json(records, scope_label)
|
|
|
940
996
|
}
|
|
941
997
|
end
|
|
942
998
|
|
|
999
|
+
# ---------------------------------------------------------------------------
|
|
1000
|
+
# Screen renderer (intent 331d) - dashboard.rb continue|project <slug> --screen.
|
|
1001
|
+
#
|
|
1002
|
+
# Sources every fact through the same helpers report-screen and DaySummary
|
|
1003
|
+
# already use (Resolved contract, ACTION_1): a missing source prints "not
|
|
1004
|
+
# recorded" or "none", never a guess or a crash (R1). The classification
|
|
1005
|
+
# pipeline above (classify, rank_key, actionable?, QUADRANTS, disposition_of)
|
|
1006
|
+
# is read here, never edited: the screen is a new renderer over the same
|
|
1007
|
+
# records (D3), so every count and rank matches what --json already reports
|
|
1008
|
+
# for the identical scope.
|
|
1009
|
+
# ---------------------------------------------------------------------------
|
|
1010
|
+
|
|
1011
|
+
SCREEN_ACTIVE_CAP = 8
|
|
1012
|
+
SCREEN_NEXT_CAP = 6
|
|
1013
|
+
SCREEN_NOT_RECORDED = "not recorded"
|
|
1014
|
+
|
|
1015
|
+
def screen_scope_slug(scope)
|
|
1016
|
+
scope.sub(/\Aproject:/, "")
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
# Tier root: PLASTIC_HOME for "global", PLASTIC_HOME/projects/<slug> for
|
|
1020
|
+
# "project:<slug>" (Resolved contract) - the same tier scripts/lib/qmd_sync.rb
|
|
1021
|
+
# already derives from a store path. Global's own roadmaps/INDEX.md sit
|
|
1022
|
+
# directly under PLASTIC_HOME, with no intervening "store" segment.
|
|
1023
|
+
def screen_tier_root(plastic_home, scope)
|
|
1024
|
+
return plastic_home if scope == "global"
|
|
1025
|
+
File.join(plastic_home, "projects", screen_scope_slug(scope))
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# "global" spans every store (the same aggregate render_continue and --json's
|
|
1029
|
+
# continue-mode subset already read); a project scope narrows to its own
|
|
1030
|
+
# records. Never a project-only helper on the unscoped path (D10).
|
|
1031
|
+
def screen_scoped_records(records, scope)
|
|
1032
|
+
return records if scope == "global"
|
|
1033
|
+
records.select { |r| r[:scope] == scope }
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
def screen_active_count(scoped)
|
|
1037
|
+
scoped.count { |r| r[:status] == "active" }
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
# D2: a stale or absent lock never counts as delivering.
|
|
1041
|
+
def screen_in_delivery_count(scoped, now:)
|
|
1042
|
+
scoped.count do |r|
|
|
1043
|
+
r[:status] == "active" && Lock.who(r[:intent_dir], now: now)["state"] == "fresh"
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
# D3: completed_on (the INDEX.md completion date) is the source of truth,
|
|
1048
|
+
# rec[:done_at] (the savepoint's own Done timestamp) the fallback when a
|
|
1049
|
+
# completed intent has no dated INDEX entry yet. `created` is never read.
|
|
1050
|
+
def screen_completion_date(rec)
|
|
1051
|
+
raw = rec[:completed_on].to_s
|
|
1052
|
+
raw = rec[:done_at].to_s if raw.empty?
|
|
1053
|
+
return nil if raw.empty?
|
|
1054
|
+
begin
|
|
1055
|
+
Date.parse(raw)
|
|
1056
|
+
rescue ArgumentError, TypeError
|
|
1057
|
+
nil
|
|
1058
|
+
end
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
def screen_delivered_count(scoped, now:)
|
|
1062
|
+
today_date = now.to_date
|
|
1063
|
+
scoped.count do |r|
|
|
1064
|
+
next false unless r[:status] == "completed"
|
|
1065
|
+
date = screen_completion_date(r)
|
|
1066
|
+
next false unless date
|
|
1067
|
+
diff = (today_date - date).to_i
|
|
1068
|
+
diff >= 0 && diff <= 7
|
|
1069
|
+
end
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
# "none" on state none/tie/exhausted or a missing roadmaps dir (R1, D4); the
|
|
1073
|
+
# live frontier otherwise (D17). RoadmapQueue itself tolerates an absent
|
|
1074
|
+
# directory (Dir.exist? guard), so this never crashes on a bare tier.
|
|
1075
|
+
def screen_roadmap_field(plastic_home, scope, now:)
|
|
1076
|
+
tier = screen_tier_root(plastic_home, scope)
|
|
1077
|
+
reader = RoadmapQueue.new(roadmaps_dir: File.join(tier, "roadmaps"),
|
|
1078
|
+
index_path: File.join(tier, "INDEX.md"), now: now)
|
|
1079
|
+
payload = reader.which
|
|
1080
|
+
return "none" if %w[none tie exhausted].include?(payload["state"])
|
|
1081
|
+
"#{payload['roadmap']} · #{payload['frontier_wave']}"
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# Sessions are global, never per-project (Resolved contract): always the
|
|
1085
|
+
# PLASTIC_HOME/store tmp root, and `session: nil` (A6) so the calling
|
|
1086
|
+
# session's own live heartbeat counts rather than being excluded as "self".
|
|
1087
|
+
def screen_sessions_count(plastic_home, now:)
|
|
1088
|
+
store = File.join(plastic_home, "store")
|
|
1089
|
+
DaySummary.active_sessions(store, nil, now: now, ttl: DaySummary::HEARTBEAT_TTL).size
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
def screen_changed_field(scoped)
|
|
1093
|
+
latest = scoped.map { |r| r[:last_accessed_at].to_s }.reject(&:empty?).max
|
|
1094
|
+
return SCREEN_NOT_RECORDED unless latest
|
|
1095
|
+
t = begin
|
|
1096
|
+
Time.parse(latest)
|
|
1097
|
+
rescue ArgumentError, TypeError
|
|
1098
|
+
nil
|
|
1099
|
+
end
|
|
1100
|
+
return SCREEN_NOT_RECORDED unless t
|
|
1101
|
+
t.utc.strftime("%Y-%m-%d %H:%M UTC")
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
# D6, intent 331f: the one Lead freshness rule every screen shares - fresh prints
|
|
1105
|
+
# "agent · key", a stale lock prints "stale · N min" (never "not recorded" or a bare "idle",
|
|
1106
|
+
# which would either hide the staleness or contradict a live In-delivery count with a dead
|
|
1107
|
+
# lead), and no lock (or one that will not read) prints "idle".
|
|
1108
|
+
def screen_lead_field(rec, now:)
|
|
1109
|
+
ReportScreen.lead_cell(rec[:intent_dir], now: now)
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
# D3 (331d1): the owner ruled no rendered row exceeds 115 visible columns.
|
|
1113
|
+
# The bound is on the WHOLE pipe-delimited row, not on one cell, so the
|
|
1114
|
+
# Intent cell gets whatever the other cells leave it. A cell short enough on
|
|
1115
|
+
# its own still drifts the row past the bound once the bar, the lead and the
|
|
1116
|
+
# scaffolding are added, which is the failure this measures away.
|
|
1117
|
+
SCREEN_ROW_MAX_COLUMNS = 115
|
|
1118
|
+
|
|
1119
|
+
# D2 (331d1): the Intent cell carries the intent line up to but not including
|
|
1120
|
+
# its first colon. A Plastic intent line opens with a short name and then
|
|
1121
|
+
# explains itself after a colon, so the lead IS the name; a line with no
|
|
1122
|
+
# colon is already a name and passes through whole. Escaping happens here, so
|
|
1123
|
+
# the budget below measures what actually reaches the row.
|
|
1124
|
+
def screen_intent_title(rec)
|
|
1125
|
+
# D8: one colon rule for every screen, on ReportScreen. `max:` is the whole line here
|
|
1126
|
+
# because screen_fit_intent does this cell's own width budgeting a moment later.
|
|
1127
|
+
cell(ReportScreen.title_before_colon(rec[:intent], max: rec[:intent].to_s.length + 1))
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
# The Intent cell fitted to what the row has left. `others` are the already
|
|
1131
|
+
# rendered sibling cells; the scaffolding is the leading "| ", a " | " between
|
|
1132
|
+
# every pair of cells, and the trailing " |". Intent 331f1: the one implementation now lives
|
|
1133
|
+
# on ReportScreen (fit_row_cell), measured in DISPLAY columns rather than String#length - an
|
|
1134
|
+
# `others` cell carrying a progress bar costs two columns per glyph, not one (RC1) - so
|
|
1135
|
+
# roadmap_state_entries_table's own Intent cell spends the same budget by the same rule.
|
|
1136
|
+
def screen_fit_intent(title, others)
|
|
1137
|
+
ReportScreen.fit_row_cell(title, others, max: SCREEN_ROW_MAX_COLUMNS)
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
# D6: last_accessed_at descending, then id, capped at SCREEN_ACTIVE_CAP.
|
|
1141
|
+
def screen_where_we_are(scoped, now:)
|
|
1142
|
+
active = scoped.select { |r| r[:status] == "active" }
|
|
1143
|
+
ordered = active.sort_by { |r| [invert_ts(r[:last_accessed_at]), r[:id]] }
|
|
1144
|
+
ordered.first(SCREEN_ACTIVE_CAP).map do |r|
|
|
1145
|
+
items = IntentScreen.checklist_items(r[:intent_dir])
|
|
1146
|
+
progress = IntentScreen.progress_fields(items)
|
|
1147
|
+
graph_id = r[:id].to_s
|
|
1148
|
+
stage = r[:lifecycle].to_s.capitalize
|
|
1149
|
+
bar = "#{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']}"
|
|
1150
|
+
lead = screen_lead_field(r, now: now)
|
|
1151
|
+
{
|
|
1152
|
+
graph_id: graph_id,
|
|
1153
|
+
intent: screen_fit_intent(screen_intent_title(r), [graph_id, stage, bar, lead]),
|
|
1154
|
+
stage: stage,
|
|
1155
|
+
progress: bar,
|
|
1156
|
+
lead: lead,
|
|
1157
|
+
}
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# A2: the exact pool render_json's dispatchable_queue ranks - actionable?
|
|
1162
|
+
# records, rank_key order, filtered to disposition defer/research (D12
|
|
1163
|
+
# excludes drive/triage). Rank is that queue's own 1-based position, so a
|
|
1164
|
+
# capped display row's rank always agrees with the uncapped --json contract.
|
|
1165
|
+
def screen_dispatchable_pool(scope_records)
|
|
1166
|
+
ranked = scope_records.select { |r| actionable?(r) }.sort_by { |r| rank_key(r) }
|
|
1167
|
+
ranked.select { |r| %w[defer research].include?(r[:disposition]) }
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
def screen_where_we_go_next(scope_records)
|
|
1171
|
+
pool = screen_dispatchable_pool(scope_records)
|
|
1172
|
+
pool.each_with_index.map do |r, i|
|
|
1173
|
+
rank = i + 1
|
|
1174
|
+
graph_id = r[:id].to_s
|
|
1175
|
+
reason = r[:quadrant].to_s
|
|
1176
|
+
{
|
|
1177
|
+
rank: rank,
|
|
1178
|
+
graph_id: graph_id,
|
|
1179
|
+
intent: screen_fit_intent(screen_intent_title(r), [rank.to_s, graph_id, reason]),
|
|
1180
|
+
reason: reason,
|
|
1181
|
+
}
|
|
1182
|
+
end.first(SCREEN_NEXT_CAP)
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def screen_fields(records, scope, plastic_home:, now: Time.now)
|
|
1186
|
+
scoped = screen_scoped_records(records, scope)
|
|
1187
|
+
{
|
|
1188
|
+
scope: scope,
|
|
1189
|
+
active: screen_active_count(scoped),
|
|
1190
|
+
in_delivery: screen_in_delivery_count(scoped, now: now),
|
|
1191
|
+
delivered: screen_delivered_count(scoped, now: now),
|
|
1192
|
+
roadmap: screen_roadmap_field(plastic_home, scope, now: now),
|
|
1193
|
+
sessions: screen_sessions_count(plastic_home, now: now),
|
|
1194
|
+
changed: screen_changed_field(scoped),
|
|
1195
|
+
where_we_are: screen_where_we_are(scoped, now: now),
|
|
1196
|
+
where_we_go_next: screen_where_we_go_next(scoped),
|
|
1197
|
+
}
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
def render_screen(records, scope, plastic_home: PLASTIC_HOME, now: Time.now)
|
|
1201
|
+
DashboardScreen.render(screen_fields(records, scope, plastic_home: plastic_home, now: now))
|
|
1202
|
+
end
|
|
1203
|
+
|
|
943
1204
|
# ---------------------------------------------------------------------------
|
|
944
1205
|
# CLI
|
|
945
1206
|
# ---------------------------------------------------------------------------
|
|
@@ -968,6 +1229,8 @@ def main(argv)
|
|
|
968
1229
|
json = argv.delete("--json")
|
|
969
1230
|
data = argv.delete("--data")
|
|
970
1231
|
plain = argv.delete("--plain")
|
|
1232
|
+
screen = argv.delete("--screen")
|
|
1233
|
+
ansi = argv.delete("--ansi")
|
|
971
1234
|
all = argv.delete("--all")
|
|
972
1235
|
limit_active = extract_flag_value(argv, "--limit-active")
|
|
973
1236
|
limit_next = extract_flag_value(argv, "--limit-next")
|
|
@@ -1018,6 +1281,22 @@ def main(argv)
|
|
|
1018
1281
|
return 0
|
|
1019
1282
|
end
|
|
1020
1283
|
|
|
1284
|
+
if screen
|
|
1285
|
+
if mode == "project" && (slug.nil? || slug.empty?)
|
|
1286
|
+
warn "usage: dashboard.rb project <slug> --screen"
|
|
1287
|
+
return 2
|
|
1288
|
+
end
|
|
1289
|
+
scope = mode == "project" ? "project:#{slug}" : "global"
|
|
1290
|
+
text = render_screen(records, scope, plastic_home: PLASTIC_HOME, now: Time.now)
|
|
1291
|
+
# A4: the identical capability guard scripts/report-screen:161 applies -
|
|
1292
|
+
# NO_COLOR always wins to plain; a non-tty stdout stays plain unless the
|
|
1293
|
+
# PLASTIC_FORCE_COLOR test seam is set.
|
|
1294
|
+
ansi_enabled = ansi && ENV["NO_COLOR"].to_s.empty? &&
|
|
1295
|
+
($stdout.tty? || ENV["PLASTIC_FORCE_COLOR"] == "1")
|
|
1296
|
+
print(ansi_enabled ? (ScreenPaint.paint(text, color: true) || text) : text)
|
|
1297
|
+
return 0
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1021
1300
|
case mode
|
|
1022
1301
|
when "continue" then print render_continue(records)
|
|
1023
1302
|
when "project"
|