@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.
- package/hooks/message-display +31 -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 +128 -0
- package/scripts/lib/installer_core.rb +23 -3
- package/scripts/lib/message_display.rb +151 -37
- package/scripts/lib/report_screen.rb +1139 -35
- 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 +244 -12
- 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_ledger.rb +4 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/report-screen +127 -10
- package/scripts/savepoint-note +11 -9
- package/skills/auto/SKILL.md +13 -12
- package/skills/auto/references/human-report-contract.md +83 -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 +28 -26
- 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,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
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"
|
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
|
|
1166
|
-
# FAIL-severity,
|
|
1167
|
-
# binding advisory-only ruling (see spec.md D2/D8 - do NOT escalate it to FAIL),
|
|
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
|