@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.
- package/hooks/message-display +55 -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 +211 -0
- package/scripts/lib/installer_core.rb +23 -3
- package/scripts/lib/message_display.rb +267 -47
- package/scripts/lib/report_screen.rb +837 -18
- 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 +240 -11
- 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/verify_intent.rb +33 -0
- package/scripts/report-screen +41 -7
- package/scripts/savepoint-note +11 -9
- package/skills/auto/SKILL.md +9 -9
- package/skills/auto/references/human-report-contract.md +79 -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 +19 -21
- 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/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
|
|
@@ -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
|
-
|
|
95
|
-
JSON
|
|
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
|
|