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

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 (42) hide show
  1. package/hooks/message-display +31 -2
  2. package/package.json +1 -1
  3. package/scripts/dashboard.rb +238 -8
  4. package/scripts/doctor.rb +291 -4
  5. package/scripts/lib/dashboard_screen.rb +40 -0
  6. package/scripts/lib/doctor_core.rb +97 -2
  7. package/scripts/lib/hook_replay.rb +128 -0
  8. package/scripts/lib/installer_core.rb +23 -3
  9. package/scripts/lib/message_display.rb +151 -37
  10. package/scripts/lib/report_screen.rb +1139 -35
  11. package/scripts/lib/roadmap_queue.rb +19 -2
  12. package/scripts/lib/roadmap_savepoint.rb +36 -7
  13. package/scripts/lib/savepoint.rb +12 -0
  14. package/scripts/lib/screen_paint.rb +244 -12
  15. package/scripts/lib/screens/dashboard.rb +20 -0
  16. package/scripts/lib/screens/plan.rb +18 -0
  17. package/scripts/lib/screens/roadmap.rb +15 -0
  18. package/scripts/lib/session_ledger.rb +4 -0
  19. package/scripts/lib/verify_intent.rb +33 -0
  20. package/scripts/report-screen +127 -10
  21. package/scripts/savepoint-note +11 -9
  22. package/skills/auto/SKILL.md +13 -12
  23. package/skills/auto/references/human-report-contract.md +83 -8
  24. package/skills/dashboard/SKILL.md +13 -2
  25. package/skills/dashboard/templates/dashboard-global.md +1 -1
  26. package/skills/dashboard/templates/dashboard-project.md +2 -2
  27. package/skills/doctor/SKILL.md +10 -4
  28. package/skills/intent-continuing/SKILL.md +28 -26
  29. package/skills/intent-continuing/references/board-fill.md +9 -0
  30. package/skills/intent-ending/SKILL.md +6 -4
  31. package/skills/intent-executing/SKILL.md +2 -0
  32. package/skills/intent-speccing/SKILL.md +7 -4
  33. package/skills/roadmap/SKILL.md +9 -0
  34. package/skills/roadmap/references/file-format.md +10 -0
  35. package/templates/dashboard-screen.md +22 -0
  36. package/templates/display-fixture.md +21 -0
  37. package/templates/intent-screen.md +1 -1
  38. package/templates/report-plan.md +15 -0
  39. package/templates/report-roadmap-delivered.md +10 -0
  40. package/templates/report-roadmap-plan.md +9 -0
  41. package/templates/report-roadmap-state.md +9 -0
  42. package/templates/report-state.md +1 -1
@@ -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
- *'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
61
- *'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
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,6 +84,25 @@ 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.12",
3
+ "version": "2.0.0-alpha.14",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- t = text.to_s
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"
package/scripts/doctor.rb CHANGED
@@ -10,8 +10,13 @@
10
10
  # Read-only — never modifies files.
11
11
 
12
12
  require "date"
13
+ require "open3"
14
+ require "timeout"
15
+ require "fileutils"
16
+ require "tmpdir"
13
17
 
14
18
  require_relative "lib/doctor_core"
19
+ require_relative "lib/hook_replay"
15
20
 
16
21
  require_relative "lib/doctor_exclusions"
17
22
  require_relative "lib/qmd_sync"
@@ -47,6 +52,13 @@ class Doctor
47
52
  # (intent 60). Alias it here so the two can never drift.
48
53
  REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
49
54
 
55
+ # Repo root, for the three display checks below that read shipped, static
56
+ # package content (the fixture fallback, the harness-adapters doc) rather
57
+ # than a runtime plastic_home path. Same derivation as
58
+ # StoreProvisioning::PACKAGE_ROOT (intent 61): scripts/doctor.rb lives one
59
+ # level under the root.
60
+ PACKAGE_ROOT = File.expand_path("..", __dir__)
61
+
50
62
 
51
63
 
52
64
  # --- Flag parsing ---
@@ -1162,11 +1174,12 @@ end
1162
1174
  #
1163
1175
  # Doctor's fourth check scope, invoked by `--intent <id>`. Never a store-wide sweep:
1164
1176
  # resolves exactly one intent directory (mirrors scripts/end-intent's resolve_intent_dir /
1165
- # scripts/project-links's --intent disambiguation) and returns six checks, four
1166
- # FAIL-severity, two WARN-severity: intent_savepoint_truthful stays WARN per intent 134's
1167
- # binding advisory-only ruling (see spec.md D2/D8 - do NOT escalate it to FAIL), and
1177
+ # scripts/project-links's --intent disambiguation) and returns seven checks, four
1178
+ # FAIL-severity, three WARN-severity: intent_savepoint_truthful stays WARN per intent 134's
1179
+ # binding advisory-only ruling (see spec.md D2/D8 - do NOT escalate it to FAIL),
1168
1180
  # intent_ticks_lag is WARN-only per intent 329's ruling that a lagging tick warns rather
1169
- # than blocks.
1181
+ # than blocks, and intent_reports_printed is WARN-only per intent 331f (never re-litigates
1182
+ # a ledger predating the Report kind).
1170
1183
  def check_intent_end(id, store: nil, disposition: nil)
1171
1184
  intent_dir, scope = resolve_single_intent_dir(id, store: store)
1172
1185
  unless intent_dir
@@ -1189,6 +1202,7 @@ end
1189
1202
  intent_lifecycle_artifacts_check(intent_dir, disposition),
1190
1203
  intent_checklist_complete_check(intent_dir),
1191
1204
  intent_ticks_lag_check(intent_dir),
1205
+ intent_reports_printed_check(intent_dir),
1192
1206
  intent_links_projection_check_for(id, scope),
1193
1207
  intent_savepoint_truthful_check(intent_dir, index_path: index_path),
1194
1208
  ]
@@ -1294,6 +1308,41 @@ end
1294
1308
  end
1295
1309
  end
1296
1310
 
1311
+ # Intent 331f: every skill that shows state during Exec is bound to print a report screen and
1312
+ # log a `Report` savepoint line (`savepoint-note --kind Report`). A commit landed with no
1313
+ # Report line anywhere in the ledger is the exact defect this check exists to catch - the
1314
+ # delivery ran but nothing on disk proves a screen was ever printed. WARN-only, like
1315
+ # intent_ticks_lag: a lead's review finding, never a machine refusal. R6: never re-litigate
1316
+ # history - an intent whose newest Commit line predates the day the Report kind shipped
1317
+ # (Savepoint::REPORT_KIND_SINCE) passes, so every pre-existing ledger keeps passing
1318
+ # `doctor --intent` (which exits 1 on an overall warn).
1319
+ def intent_reports_printed_check(intent_dir)
1320
+ path = File.join(intent_dir, "savepoint.md")
1321
+ lines = File.exist?(path) ? File.readlines(path) : []
1322
+ kinds = lines.filter_map { |l| l.strip.match(IntentScreen::SAVEPOINT_RE) }
1323
+
1324
+ commit_timestamps = kinds.select { |m| m[2] == "Commit" }.map { |m| m[1] }
1325
+ if commit_timestamps.empty?
1326
+ return check(category: "intent_end", name: "intent_reports_printed", status: "pass",
1327
+ message: "n/a: no commits recorded")
1328
+ end
1329
+
1330
+ if kinds.any? { |m| m[2] == "Report" }
1331
+ return check(category: "intent_end", name: "intent_reports_printed", status: "pass",
1332
+ message: "a Report line is recorded")
1333
+ end
1334
+
1335
+ newest_commit_date = commit_timestamps.max[0, 10]
1336
+ if newest_commit_date < Savepoint::REPORT_KIND_SINCE
1337
+ return check(category: "intent_end", name: "intent_reports_printed", status: "pass",
1338
+ message: "n/a: newest commit (#{newest_commit_date}) predates the Report " \
1339
+ "kind (#{Savepoint::REPORT_KIND_SINCE})")
1340
+ end
1341
+
1342
+ check(category: "intent_end", name: "intent_reports_printed", status: "warn",
1343
+ message: "commits are recorded and no Report line exists")
1344
+ end
1345
+
1297
1346
  # Count `Commit` lines in the intent's savepoint ledger, through the one regex that parses
1298
1347
  # a savepoint line (IntentScreen::SAVEPOINT_RE; field 2 is the kind).
1299
1348
  def savepoint_commit_count(intent_dir)
@@ -2431,6 +2480,240 @@ end
2431
2480
  end
2432
2481
  end
2433
2482
 
2483
+ # --- Check category: display (intent 331e, D1) ---
2484
+ #
2485
+ # Three of the four `display` checks live HERE, not in doctor_core.rb: they
2486
+ # need Open3/Timeout to spawn the installed hook as a real subprocess, and
2487
+ # that must never attach to the SessionStart boot path
2488
+ # (test/doctor_core_split_test.rb T2 pins doctor_core.rb's require set
2489
+ # exactly). check_display_registration (the fourth, --core-scoped) lives in
2490
+ # doctor_core.rb instead, alongside display_hook_launcher_name, which this
2491
+ # file's check_display_paints reuses to name the SAME installed launcher.
2492
+
2493
+ # The ambient defeater active for THIS invocation, or nil. `no_color` is
2494
+ # DI'd (default ENV["NO_COLOR"]) rather than read deep inside this method,
2495
+ # so a test can force it on or off without touching the real process
2496
+ # environment. Shared by check_display_paints (a defeater turns a
2497
+ # would-be fail into a pass, R3) and check_display_not_defeated (a
2498
+ # defeater is what it warns about).
2499
+ def active_display_defeater(no_color: ENV["NO_COLOR"])
2500
+ return "NO_COLOR" unless no_color.to_s.empty?
2501
+
2502
+ cfg = load_yaml_safe(File.join(plastic_home, "config.yml"))
2503
+ display_cfg = cfg.is_a?(Hash) ? cfg["display"] : nil
2504
+ return "display.ansi_screen: false in config.yml" if display_cfg.is_a?(Hash) &&
2505
+ display_cfg.fetch("ansi_screen", true) == false
2506
+
2507
+ nil
2508
+ end
2509
+
2510
+ # The shipped fixture's path: plastic_home's own templates/ first (a real
2511
+ # install), the package's own templates/ otherwise (running from a repo
2512
+ # checkout, or an install whose templates/ predates this fixture). Neither
2513
+ # existing means no fixture at all.
2514
+ def display_fixture_path(package_root: PACKAGE_ROOT)
2515
+ [File.join(plastic_home, "templates", "display-fixture.md"),
2516
+ File.join(package_root, "templates", "display-fixture.md")].find { |p| File.file?(p) }
2517
+ end
2518
+
2519
+ # The fixture's replayable text: its header comment (see
2520
+ # templates/display-fixture.md) is for a human reading the file on disk,
2521
+ # never sent through the hook.
2522
+ def display_fixture_text(path)
2523
+ File.read(path).sub(/\A<!--.*?-->\n\n?/m, "")
2524
+ end
2525
+
2526
+ # display_hook_paints (D1, R1/R2/R3): replays the shipped fixture through
2527
+ # the INSTALLED launcher (`<agent_dir>/hooks/plastic-message-display`,
2528
+ # never this package's own hooks/message-display. That is R1's whole point: an
2529
+ # installed launcher can predate the package's, and replaying the wrong
2530
+ # one reports pass while the real stack is stale) and expects a painted
2531
+ # (ANSI) screen back.
2532
+ #
2533
+ # A known defeater active (NO_COLOR, or display.ansi_screen: false) turns
2534
+ # a no-SGR result into a PASS, naming the defeater and noting it reflects
2535
+ # this invocation's own environment (R3). display_not_defeated is what
2536
+ # warns about a defeater; this check never fails because of one.
2537
+ def check_display_paints(agent_key, no_color: ENV["NO_COLOR"],
2538
+ tmp_dir_factory: -> { Dir.mktmpdir("plastic-doctor-display") },
2539
+ timeout_seconds: 10, package_root: PACKAGE_ROOT)
2540
+ config = agents[agent_key]
2541
+ unless agent_key == "claude"
2542
+ return [check(
2543
+ category: "display", name: "display_hook_paints", status: "pass",
2544
+ message: "#{config[:name]} is plain by contract; no display hook to replay"
2545
+ )]
2546
+ end
2547
+
2548
+ fixture_path = display_fixture_path(package_root: package_root)
2549
+ unless fixture_path
2550
+ return [check(
2551
+ category: "display", name: "display_hook_paints", status: "fail",
2552
+ message: "Shipped display fixture is missing (templates/display-fixture.md)",
2553
+ fixable: false
2554
+ )]
2555
+ end
2556
+
2557
+ agent_dir = config[:dir]
2558
+ launcher_path = File.join(agent_dir, "hooks", display_hook_launcher_name)
2559
+ unless File.file?(launcher_path) && File.executable?(launcher_path)
2560
+ return [check(
2561
+ category: "display", name: "display_hook_paints", status: "fail",
2562
+ message: "Installed launcher #{tilde(launcher_path)} is missing or not executable; cannot replay",
2563
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
2564
+ )]
2565
+ end
2566
+
2567
+ text = display_fixture_text(fixture_path)
2568
+ tmp_dir = tmp_dir_factory.call
2569
+ outs =
2570
+ begin
2571
+ HookReplay.replay(hook_path: launcher_path, tmp_root: tmp_dir, text: text,
2572
+ env: { "PLASTIC_HOME" => plastic_home }, timeout: timeout_seconds)
2573
+ rescue StandardError => e
2574
+ # Process.spawn (inside HookReplay) can raise before a pid ever
2575
+ # exists: a permissions race, or a launcher that vanishes between the
2576
+ # executable? check above and the spawn, or any other unexpected
2577
+ # error. Unlike this codebase's defensive style elsewhere
2578
+ # (read_json_safe, load_yaml_safe), nothing here degraded that into
2579
+ # a clean check result, so an unlucky replay crashed the entire
2580
+ # doctor run instead of failing just this one check (intent 331e,
2581
+ # F6). `return` still runs the `ensure` below before unwinding.
2582
+ return [check(
2583
+ category: "display", name: "display_hook_paints", status: "fail",
2584
+ message: "Replaying the installed launcher raised #{e.class}: #{e.message}",
2585
+ fixable: false
2586
+ )]
2587
+ ensure
2588
+ FileUtils.remove_entry(tmp_dir) if tmp_dir && File.exist?(tmp_dir)
2589
+ end
2590
+
2591
+ if HookReplay.timed_out?(outs)
2592
+ return [check(
2593
+ category: "display", name: "display_hook_paints", status: "fail",
2594
+ message: "Replaying the installed launcher timed out after #{timeout_seconds}s; painting could not be verified",
2595
+ fixable: false
2596
+ )]
2597
+ end
2598
+
2599
+ defeater = active_display_defeater(no_color: no_color)
2600
+ if defeater
2601
+ return [check(
2602
+ category: "display", name: "display_hook_paints", status: "pass",
2603
+ message: "Painting is defeated by #{defeater} for this invocation; the replay's plain " \
2604
+ "output reflects that setting, not a broken hook"
2605
+ )]
2606
+ end
2607
+
2608
+ content = HookReplay.final_display_content(outs)
2609
+ if content.to_s.include?("\e[")
2610
+ [check(
2611
+ category: "display", name: "display_hook_paints", status: "pass",
2612
+ message: "The installed hook returned a painted (ANSI) screen"
2613
+ )]
2614
+ else
2615
+ [check(
2616
+ category: "display", name: "display_hook_paints", status: "fail",
2617
+ message: "The installed hook returned no ANSI escape sequence; painting may be broken",
2618
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
2619
+ )]
2620
+ end
2621
+ end
2622
+
2623
+ # display_not_defeated (D1, R3/R4): warns, never fails, on each active
2624
+ # defeater, naming the setting and its effect. Every result, pass or warn alike,
2625
+ # names the verbose transcript view (Ctrl+O, R4): it redraws every
2626
+ # screen as plain tables too, but it has no on-disk setting doctor can
2627
+ # read, so its absence from these warnings is never proof that view paints.
2628
+ def check_display_not_defeated(agent_key, no_color: ENV["NO_COLOR"])
2629
+ config = agents[agent_key]
2630
+ unless agent_key == "claude"
2631
+ return [check(
2632
+ category: "display", name: "display_not_defeated", status: "pass",
2633
+ message: "#{config[:name]} is plain by contract; no display defeaters apply"
2634
+ )]
2635
+ end
2636
+
2637
+ warnings = []
2638
+ warnings << "NO_COLOR is set: forces every screen to plain text for this invocation" unless no_color.to_s.empty?
2639
+
2640
+ cfg = load_yaml_safe(File.join(plastic_home, "config.yml"))
2641
+ display_cfg = cfg.is_a?(Hash) ? cfg["display"] : nil
2642
+ if display_cfg.is_a?(Hash) && display_cfg.fetch("ansi_screen", true) == false
2643
+ warnings << "config.yml sets display.ansi_screen: false: forces every screen to plain text"
2644
+ end
2645
+
2646
+ settings = read_json_safe(File.join(config[:dir], "settings.json"))
2647
+ if settings.is_a?(Hash) && settings["verbose"] == true
2648
+ warnings << "settings.json sets verbose: true: the verbose transcript view (Ctrl+O) redraws every screen as plain tables"
2649
+ end
2650
+
2651
+ transcript_note = "The Ctrl+O verbose transcript view also redraws every screen as plain " \
2652
+ "tables and has no on-disk setting; its absence above is never proof that view paints."
2653
+
2654
+ if warnings.empty?
2655
+ [check(
2656
+ category: "display", name: "display_not_defeated", status: "pass",
2657
+ message: "No known display defeaters active", details: [transcript_note]
2658
+ )]
2659
+ else
2660
+ [check(
2661
+ category: "display", name: "display_not_defeated", status: "warn",
2662
+ message: "#{warnings.size} display defeater(s) active",
2663
+ details: warnings + [transcript_note]
2664
+ )]
2665
+ end
2666
+ end
2667
+
2668
+ # display_surfaces_documented (D1/D4): the harness-adapters doc names the
2669
+ # three surface classes. Reads the package's own shipped doc: static
2670
+ # content, not a runtime path, the same shape as check_skill_lint reading the
2671
+ # package's own skills/ tree.
2672
+ #
2673
+ # `docs/` ships in NEITHER package.json's `files` list NOR
2674
+ # InstallerCore's manifest (grep confirms zero references), so on every
2675
+ # real install `package_root` resolves to a `~/.plastic` that has no
2676
+ # `docs/` tree at all; only a repo checkout carries it. Absence of the
2677
+ # doc there is therefore not a defect to report; it is this install
2678
+ # having nothing to verify, the same skip-as-pass vocabulary D3 and R3
2679
+ # already use elsewhere in this category. This check fails only when the
2680
+ # doc DOES exist (a repo checkout) but has rotted: no `## Surfaces`
2681
+ # section, or one missing a required literal.
2682
+ def check_display_surfaces_documented(package_root: PACKAGE_ROOT)
2683
+ doc_path = File.join(package_root, "docs", "reference", "harness-adapters.md")
2684
+
2685
+ unless File.file?(doc_path)
2686
+ return [check(
2687
+ category: "display", name: "display_surfaces_documented", status: "pass",
2688
+ message: "Reference docs are not shipped to this install (#{tilde(doc_path)} absent); " \
2689
+ "nothing to verify"
2690
+ )]
2691
+ end
2692
+
2693
+ content = File.read(doc_path)
2694
+ section = content[/^## Surfaces\n(.*?)(?=\n## |\z)/m, 1].to_s
2695
+
2696
+ required = ["Claude Code normal view", "agents view", "Codex", "claude -p", "verbose transcript view"]
2697
+ missing = required.reject { |literal| section.include?(literal) }
2698
+
2699
+ if section.empty?
2700
+ [check(
2701
+ category: "display", name: "display_surfaces_documented", status: "fail",
2702
+ message: "docs/reference/harness-adapters.md has no ## Surfaces section", fixable: false
2703
+ )]
2704
+ elsif missing.empty?
2705
+ [check(
2706
+ category: "display", name: "display_surfaces_documented", status: "pass",
2707
+ message: "harness-adapters.md documents every display surface class"
2708
+ )]
2709
+ else
2710
+ [check(
2711
+ category: "display", name: "display_surfaces_documented", status: "fail",
2712
+ message: "## Surfaces section is missing: #{missing.join(', ')}", details: missing, fixable: false
2713
+ )]
2714
+ end
2715
+ end
2716
+
2434
2717
  # --- Run all checks ---
2435
2718
 
2436
2719
  def run_checks(agent_key)
@@ -2449,6 +2732,10 @@ end
2449
2732
  all_checks += check_session_ledger(scopes: ["global"])
2450
2733
  all_checks += check_skill_lint
2451
2734
  all_checks += check_install_integrity
2735
+ all_checks += check_display_registration(agent_key)
2736
+ all_checks += check_display_paints(agent_key)
2737
+ all_checks += check_display_not_defeated(agent_key)
2738
+ all_checks += check_display_surfaces_documented
2452
2739
 
2453
2740
  summarize(all_checks, agent_key)
2454
2741
  end