@zalom/plastic 2.0.0-alpha.13 → 2.0.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/hooks/message-display +55 -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 +211 -0
  8. package/scripts/lib/installer_core.rb +23 -3
  9. package/scripts/lib/message_display.rb +267 -47
  10. package/scripts/lib/report_screen.rb +837 -18
  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 +240 -11
  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/verify_intent.rb +33 -0
  19. package/scripts/report-screen +41 -7
  20. package/scripts/savepoint-note +11 -9
  21. package/skills/auto/SKILL.md +9 -9
  22. package/skills/auto/references/human-report-contract.md +79 -8
  23. package/skills/dashboard/SKILL.md +13 -2
  24. package/skills/dashboard/templates/dashboard-global.md +1 -1
  25. package/skills/dashboard/templates/dashboard-project.md +2 -2
  26. package/skills/doctor/SKILL.md +10 -4
  27. package/skills/intent-continuing/SKILL.md +19 -21
  28. package/skills/intent-continuing/references/board-fill.md +9 -0
  29. package/skills/intent-ending/SKILL.md +6 -4
  30. package/skills/intent-executing/SKILL.md +2 -0
  31. package/skills/intent-speccing/SKILL.md +7 -4
  32. package/skills/roadmap/SKILL.md +9 -0
  33. package/skills/roadmap/references/file-format.md +10 -0
  34. package/templates/dashboard-screen.md +22 -0
  35. package/templates/display-fixture.md +21 -0
  36. package/templates/intent-screen.md +1 -1
  37. package/templates/report-plan.md +15 -0
  38. package/templates/report-roadmap-delivered.md +10 -0
  39. package/templates/report-roadmap-plan.md +9 -0
  40. package/templates/report-roadmap-state.md +9 -0
  41. 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,8 +84,51 @@ else
74
84
  *'"delta":""'*|*'"delta": ""'*) handoff=1 ;;
75
85
  *'"delta":"\n"'*|*'"delta": "\n"'*) handoff=1 ;;
76
86
  esac
87
+ # 331a (D5): the hook engages anywhere. An opener can now fall ANYWHERE
88
+ # inside a later chunk's own delta, not only at its start (the shape
89
+ # tests just above), so these globs search the delta value rather than
90
+ # anchor to its opening quote. Still anchored to the "delta":" key itself
91
+ # (never dropped, matrix M12d) so an ordinary field elsewhere in the
92
+ # payload can never be mistaken for the delta's own text; within that,
93
+ # every shipped opener (## ▶, ## ✔, ▶ In delivery, ✔ + digit) contains a
94
+ # bare ▶ or ✔, so matching the bare glyph anywhere covers all four at
95
+ # once. Prose that merely shapes like an opener still hands off this way
96
+ # (an accepted over-match: Ruby's own grammar is the final, stricter
97
+ # arbiter and fails open on anything that is not a real one). Raw glyph
98
+ # and \u-escaped forms both count, since the encoder's choice is not ours
99
+ # to predict.
100
+ case $INPUT in
101
+ *'"delta":"'*'▶'*|*'"delta": "'*'▶'*) handoff=1 ;;
102
+ *'"delta":"'*'✔'*|*'"delta": "'*'✔'*) handoff=1 ;;
103
+ *'"delta":"'*'\u25b6'*|*'"delta": "'*'\u25b6'*) handoff=1 ;;
104
+ *'"delta":"'*'\u2714'*|*'"delta": "'*'\u2714'*) handoff=1 ;;
105
+ esac
77
106
  fi
78
107
 
79
108
  [ "$handoff" = 1 ] || exit 0
80
109
 
110
+ # 331a1 (D1): chunk 0 is about to hand off to Ruby, which takes on the
111
+ # order of 150 ms to boot before it ever writes SCREEN or NOSCREEN. Claude
112
+ # Code fires the per-chunk hook PROCESSES CONCURRENTLY (see the header
113
+ # comment above), so a later chunk landing inside that window used to find
114
+ # no message directory at all, fail this script's own cheap shape test,
115
+ # and pass through as raw Markdown - measured at 15 to 21 chunks of every
116
+ # screen, against a stream-realistic 5 ms stagger. Staking a PENDING
117
+ # marker here, the instant the handoff gate above passes, closes that
118
+ # window down to microseconds: a later chunk's own "does the directory
119
+ # exist" check (below, unchanged) now finds it almost at once, and
120
+ # MessageDisplay polls for the real decision instead of judging the
121
+ # chunk's own shape. mkdir is the one non-builtin on this whole path - it
122
+ # forks, unlike everything else in this file - but it runs ONLY here,
123
+ # after the handoff gate, so the ordinary (non-candidate) chunk that is
124
+ # the overwhelming common case still forks nothing (matrix L8). A failed
125
+ # mkdir or printf (an unwritable tmp root, a race with another process) is
126
+ # silently swallowed: it must never change this script's own exit status
127
+ # or skip the Ruby handoff below - Ruby's own polling already fails open
128
+ # (D5) when no decision ever appears.
129
+ if [ "$is_index_zero" = 1 ]; then
130
+ mkdir -p "$MSGDIR" 2>/dev/null
131
+ printf '' > "$MSGDIR/PENDING" 2>/dev/null
132
+ fi
133
+
81
134
  printf '%s' "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-message-display"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.13",
3
+ "version": "2.0.0-alpha.15",
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"