@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.
Files changed (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. package/templates/roadmap.md +13 -0
@@ -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
- # Returns the set of intent ids listed under a given INDEX.md section.
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(/^- \[([^\]\s]+)/).flatten
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
- COMPLETION_DATE_RE = /^- \[([^\]\s]+).*?\)\s*[\u2014-]\s*(\d{4}-\d{2}-\d{2})\b/
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
- # Deterministic, content-derived. Observability: a populated "## Completed" section that
86
- # yields not one single dated entry is a parser regression, not a legitimately empty
87
- # result, so it is surfaced with a stderr warning rather than rotting invisibly (the same
88
- # silent-failure class intent 202's gate review caught this file already committing).
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
- t = text.to_s
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"