@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
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
@@ -0,0 +1,40 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "report_screen"
5
+
6
+ # DashboardScreen (intent 331d) - the presentation half of the dashboard
7
+ # screen. scripts/dashboard.rb's screen_fields sources every fact (Active, In
8
+ # delivery, Delivered, Roadmap, Sessions, Changed, the two capped row lists)
9
+ # through the same helpers report-screen and DaySummary already use, turning
10
+ # a missing source into "not recorded" or "none" before it ever reaches
11
+ # here; this module carries no data-sourcing logic of its own, only layout,
12
+ # exactly like ReportScreen.render_state and IntentScreen.render do for
13
+ # their own screens.
14
+ module DashboardScreen
15
+ module_function
16
+
17
+ TEMPLATE_PATH = File.expand_path("../../templates/dashboard-screen.md", __dir__)
18
+
19
+ def render(fields, template: nil)
20
+ out = (template || File.read(TEMPLATE_PATH)).dup
21
+ out = out.gsub("{{scope}}", fields.fetch(:scope).to_s)
22
+ out = out.gsub("{{active}}", fields.fetch(:active).to_s)
23
+ out = out.gsub("{{in_delivery}}", fields.fetch(:in_delivery).to_s)
24
+ out = out.gsub("{{delivered}}", fields.fetch(:delivered).to_s)
25
+ out = out.gsub("{{roadmap}}", fields.fetch(:roadmap).to_s)
26
+ out = out.gsub("{{sessions}}", fields.fetch(:sessions).to_s)
27
+ out = out.gsub("{{changed}}", fields.fetch(:changed).to_s)
28
+ out = out.gsub("{{where_we_are.rows}}", where_we_are_rows(fields.fetch(:where_we_are, [])))
29
+ out = out.gsub("{{where_we_go_next.rows}}", where_we_go_next_rows(fields.fetch(:where_we_go_next, [])))
30
+ ReportScreen.fit_screen(out.gsub(/\n{3,}/, "\n\n"))
31
+ end
32
+
33
+ def where_we_are_rows(rows)
34
+ rows.map { |r| "| #{r[:graph_id]} | #{r[:intent]} | #{r[:stage]} | #{r[:progress]} | #{r[:lead]} |" }.join("\n")
35
+ end
36
+
37
+ def where_we_go_next_rows(rows)
38
+ rows.map { |r| "| #{r[:rank]} | #{r[:graph_id]} | #{r[:intent]} | #{r[:reason]} |" }.join("\n")
39
+ end
40
+ end
@@ -91,8 +91,19 @@ class Doctor
91
91
 
92
92
  JSON.parse(File.read(path))
93
93
  rescue JSON::ParserError
94
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
95
- JSON.parse(content)
94
+ # The comment/trailing-comma-stripped retry below can itself raise
95
+ # JSON::ParserError on genuinely malformed content (a truncated file, or
96
+ # plain garbage). A nested begin/rescue is required here because a
97
+ # method-level `rescue` clause never catches an exception raised from
98
+ # INSIDE a sibling rescue clause's own body (only from the main body).
99
+ # Without this nesting a malformed settings.json crashes doctor instead
100
+ # of reporting a clean fail (intent 331e, F5).
101
+ begin
102
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
103
+ JSON.parse(content)
104
+ rescue
105
+ nil
106
+ end
96
107
  rescue
97
108
  nil
98
109
  end
@@ -1245,10 +1256,94 @@ class Doctor
1245
1256
  all_checks += check_manifest_sync(agent_key)
1246
1257
  all_checks += check_registered_project_paths
1247
1258
  all_checks += check_global_store_available
1259
+ all_checks += check_display_registration(agent_key)
1248
1260
 
1249
1261
  summarize(all_checks, agent_key, binary: true)
1250
1262
  end
1251
1263
 
1264
+ # The single `hooks/<name>` launcher basename for the MessageDisplay event,
1265
+ # derived from HookRegistry rather than hand-kept (intent 331e), so a
1266
+ # future rename of the hook stays in one place. Shared by
1267
+ # check_display_registration (below, boot path) and scripts/doctor.rb's
1268
+ # check_display_paints (full run), which resolves the SAME name under the
1269
+ # agent_dir it was given.
1270
+ def display_hook_launcher_name
1271
+ group = HookRegistry.events["MessageDisplay"].first
1272
+ "plastic-#{group['hooks'].first['name']}"
1273
+ end
1274
+
1275
+ DISPLAY_HOOK_FIX_HINT = "Re-run the Plastic installer to repair the hook registration: " \
1276
+ "npx @zalom/plastic@<channel> install --reinstall --claude " \
1277
+ "(plastic-install --repair)".freeze
1278
+
1279
+ # display_hook_registered (intent 331e, D1, category "display"): the Claude
1280
+ # settings carry the plastic-message-display command, on-disk, executable.
1281
+ # Boot-path safe: resolves everything from the injected `agents` hash and
1282
+ # `plastic_home`, never Dir.home or a real ~/.claude (E18). This is the same
1283
+ # discipline check_claude_registration already follows.
1284
+ #
1285
+ # D3: a harness Doctor knows carries no display hook (Codex, Hermes) is a
1286
+ # pass, not a fail, worded "plain by contract" like the paint check's own
1287
+ # skip (scripts/doctor.rb's check_display_paints).
1288
+ def check_display_registration(agent_key)
1289
+ config = agents[agent_key]
1290
+ unless agent_key == "claude"
1291
+ return [check(
1292
+ category: "display", name: "display_hook_registered", status: "pass",
1293
+ message: "#{config[:name]} is plain by contract; no MessageDisplay hook to register"
1294
+ )]
1295
+ end
1296
+
1297
+ agent_dir = config[:dir]
1298
+ settings_path = File.join(agent_dir, "settings.json")
1299
+ settings = read_json_safe(settings_path)
1300
+
1301
+ if settings.nil?
1302
+ return [check(
1303
+ category: "display", name: "display_hook_registered", status: "fail",
1304
+ message: "Cannot read #{tilde(settings_path)}: file missing or invalid",
1305
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1306
+ )]
1307
+ end
1308
+
1309
+ hooks = settings["hooks"].is_a?(Hash) ? settings["hooks"] : {}
1310
+ commands = event_commands(hooks["MessageDisplay"])
1311
+ launcher_name = display_hook_launcher_name
1312
+
1313
+ registered = commands.any? { |cmd| HookRegistry.command_basenames(cmd).include?(launcher_name) }
1314
+
1315
+ unless registered
1316
+ return [check(
1317
+ category: "display", name: "display_hook_registered", status: "fail",
1318
+ message: "No MessageDisplay hook registered in #{tilde(settings_path)}",
1319
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1320
+ )]
1321
+ end
1322
+
1323
+ launcher_path = File.join(agent_dir, "hooks", launcher_name)
1324
+
1325
+ unless File.exist?(launcher_path)
1326
+ return [check(
1327
+ category: "display", name: "display_hook_registered", status: "fail",
1328
+ message: "MessageDisplay is registered but #{tilde(launcher_path)} does not exist",
1329
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1330
+ )]
1331
+ end
1332
+
1333
+ unless File.executable?(launcher_path)
1334
+ return [check(
1335
+ category: "display", name: "display_hook_registered", status: "fail",
1336
+ message: "#{tilde(launcher_path)} exists but is not executable",
1337
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
1338
+ )]
1339
+ end
1340
+
1341
+ [check(
1342
+ category: "display", name: "display_hook_registered", status: "pass",
1343
+ message: "MessageDisplay hook registered and #{tilde(launcher_path)} is executable"
1344
+ )]
1345
+ end
1346
+
1252
1347
  def check_registered_project_paths
1253
1348
  checks = []
1254
1349