@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20

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 (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. package/templates/roadmap.md +13 -0
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"
@@ -22,6 +27,9 @@ require_relative "lib/links_projection"
22
27
  require_relative "lib/links_section"
23
28
  require_relative "lib/lock"
24
29
  require_relative "lib/savepoint"
30
+ require_relative "lib/node_ledger"
31
+ require_relative "lib/ready_set"
32
+ require_relative "lib/index_projection"
25
33
  require_relative "lib/agent_models"
26
34
  require_relative "lib/outcome_guard"
27
35
  require_relative "lib/skill_lint"
@@ -30,6 +38,7 @@ require_relative "lib/power_tools"
30
38
  require_relative "lib/preflight"
31
39
  require_relative "lib/ruby_probe"
32
40
  require_relative "lib/doctor_session_ledger"
41
+ require_relative "lib/intent_screen"
33
42
 
34
43
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
35
44
  # run it hermetically (no eval, no global-constant rewriting).
@@ -46,6 +55,13 @@ class Doctor
46
55
  # (intent 60). Alias it here so the two can never drift.
47
56
  REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
48
57
 
58
+ # Repo root, for the three display checks below that read shipped, static
59
+ # package content (the fixture fallback, the harness-adapters doc) rather
60
+ # than a runtime plastic_home path. Same derivation as
61
+ # StoreProvisioning::PACKAGE_ROOT (intent 61): scripts/doctor.rb lives one
62
+ # level under the root.
63
+ PACKAGE_ROOT = File.expand_path("..", __dir__)
64
+
49
65
 
50
66
 
51
67
  # --- Flag parsing ---
@@ -288,6 +304,167 @@ class Doctor
288
304
  # content scanning. check_global_store (above) is store/full-scope and includes
289
305
  # orphaned_intents / ghost_references, both explicitly forbidden at core by D1.
290
306
 
307
+ # Row E (spec D8, spec 315b): #check_core_files's own `version_match` only ever
308
+ # compares the SELECTED harness's `<dir>/plastic/VERSION` against the global
309
+ # VERSION, so a second installed harness left behind on an old version is never
310
+ # version-checked at all -- exactly how ten stale 1.14.1 Codex registrations
311
+ # survived a `pass`. This runs the same comparison for EVERY installed harness
312
+ # (agent_dir on disk), named `version_match_<key>` so the existing `version_match`
313
+ # name and position stay reserved for the selected harness (pinned tests:
314
+ # test/doctor_test.rb:1846,1861,1873). Full tier only (`run_checks`); never
315
+ # `run_core_checks`, which is `binary: true` and runs on the session-start boot
316
+ # path, where a second-harness warn would become a boot-time error for every user.
317
+ def check_harness_versions
318
+ global_version = read_version
319
+ return [] unless global_version
320
+
321
+ agents.filter_map do |key, config|
322
+ next unless config.is_a?(Hash) && File.directory?(config[:dir].to_s)
323
+
324
+ agent_version_path = File.join(config[:dir], "plastic", "VERSION")
325
+ name = "version_match_#{key}"
326
+
327
+ if !File.exist?(agent_version_path)
328
+ check(
329
+ category: "core_files", name: name, status: "warn",
330
+ message: "#{config[:name]}'s agent-side VERSION file not found at #{tilde(agent_version_path)}",
331
+ fixable: true,
332
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
333
+ )
334
+ else
335
+ agent_version = File.read(agent_version_path).strip
336
+ if global_version == agent_version
337
+ check(
338
+ category: "core_files", name: name, status: "pass",
339
+ message: "Global VERSION (#{global_version}) matches #{config[:name]}'s agent-side VERSION"
340
+ )
341
+ else
342
+ check(
343
+ category: "core_files", name: name, status: "warn",
344
+ message: "Version mismatch for #{config[:name]}: global=#{global_version}, agent=#{agent_version}",
345
+ details: [
346
+ "#{tilde(File.join(plastic_home, "VERSION"))}: #{global_version}",
347
+ "#{tilde(agent_version_path)}: #{agent_version}",
348
+ ],
349
+ fixable: true,
350
+ fix_hint: "Re-sync the stale harness: npx @zalom/plastic@latest install --reinstall <flag>, or plastic-rollback to a prior version"
351
+ )
352
+ end
353
+ end
354
+ end
355
+ end
356
+
357
+ # The Codex hook NAME expected per event, keyed off HookRegistry's own
358
+ # constants rather than a full built command string: naming it, not the
359
+ # literal dispatcher path, is what #check_codex_stale_registrations needs,
360
+ # so this never depends on plastic_home resolving to the same install the
361
+ # live hooks.json's commands were written under.
362
+ def codex_expected_names_by_event
363
+ names = {}
364
+ post_order = HookRegistry.events["PostToolUse"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
365
+ names["PostToolUse"] = HookRegistry::CODEX_POST_HOOKS & post_order
366
+
367
+ HookRegistry::CODEX_LIVE_STATE_EVENTS.each do |event|
368
+ names[event] = HookRegistry.events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
369
+ end
370
+
371
+ end_order = HookRegistry.events["SessionEnd"].flat_map { |g| g["hooks"].map { |h| h["name"] } }
372
+ names["SessionEnd"] = HookRegistry::CODEX_SESSION_END_HOOKS & end_order
373
+ names
374
+ end
375
+
376
+ # Row E (spec D8, spec 315b): #codex_hooks_registered_check computes
377
+ # `want - got` and iterates `expected.each`, so it can see a MISSING registration
378
+ # but never an EXTRA one, and it can never even visit an event `expected` does not
379
+ # list at all (a retired event such as PreToolUse, which a stale ~/.codex/hooks.json
380
+ # can still carry). This scans the UNION of expected and live event keys instead, so
381
+ # a stale registration parked in a retired event is visible. Compares hook NAMES
382
+ # (see #codex_expected_names_by_event), not full command strings: a full-command
383
+ # comparison would embed this Doctor instance's own `plastic_home` in the expected
384
+ # dispatcher path, which is only guaranteed to match the live hooks.json's own
385
+ # dispatcher path in a real, single install, never a test that fakes one without the
386
+ # other. Full tier only (`run_checks`); never `run_core_checks` (D8).
387
+ #
388
+ # Post-execution review item 5: name-only comparison alone is blind to two real
389
+ # cases, both closed here without reintroducing any plastic_home coupling: (a) the
390
+ # SAME (event, name) pair registered more than once (each copy's name is
391
+ # individually expected, so a "not in the expected set" scan never sees the
392
+ # duplicate), and (b) a correctly-named entry whose dispatcher path is an OLD
393
+ # install, different from the path every OTHER Plastic entry in this same file
394
+ # actually uses -- the "correct" path here is whichever path the file's own
395
+ # majority of entries already agrees on, never a path this Doctor instance derives
396
+ # from its own plastic_home.
397
+ def check_codex_stale_registrations
398
+ config = agents["codex"]
399
+ return [] unless config.is_a?(Hash) && File.directory?(config[:dir].to_s)
400
+
401
+ home_dir = config[:home_dir] || config[:dir]
402
+ hooks_json = File.join(home_dir, "hooks.json")
403
+ data = read_json_safe(hooks_json)
404
+ return [] if data.nil? || !data.is_a?(Hash)
405
+
406
+ expected_names = codex_expected_names_by_event
407
+ live = data["hooks"].is_a?(Hash) ? data["hooks"] : {}
408
+
409
+ # One pass over every live Plastic entry (event, hook name, and the literal
410
+ # dispatcher-path token it actually invokes), shared by all three checks below.
411
+ entries = []
412
+ (expected_names.keys | live.keys).each do |event|
413
+ Array(live[event]).each do |group|
414
+ Array(group["hooks"]).each do |h|
415
+ cmd = h["command"]
416
+ next unless HookRegistry.codex_purge_command?(cmd)
417
+
418
+ dispatcher_path = cmd.to_s.split(/\s+/).reject(&:empty?).first.to_s.delete("\"'")
419
+ name = HookRegistry.command_basenames(cmd).last
420
+ entries << { event: event, name: name, dispatcher_path: dispatcher_path, cmd: cmd }
421
+ end
422
+ end
423
+ end
424
+
425
+ stale = []
426
+
427
+ # Not in the expected name set for this event at all (the original check).
428
+ entries.each do |e|
429
+ want_names = Array(expected_names[e[:event]])
430
+ stale << "#{e[:event]}: #{e[:cmd]}" unless want_names.include?(e[:name])
431
+ end
432
+
433
+ # (a) the same (event, name) pair registered more than once.
434
+ entries.group_by { |e| [e[:event], e[:name]] }.each_value do |group|
435
+ next if group.size <= 1
436
+
437
+ group.each { |e| stale << "#{e[:event]}: #{e[:cmd]} (duplicate registration of #{e[:name]})" }
438
+ end
439
+
440
+ # (b) a dispatcher path that differs from the path every other Plastic entry in
441
+ # this same file actually uses.
442
+ unless entries.empty?
443
+ majority_path = entries.map { |e| e[:dispatcher_path] }.tally.max_by { |(_path, count)| count }&.first
444
+ entries.each do |e|
445
+ next if e[:dispatcher_path] == majority_path
446
+
447
+ stale << "#{e[:event]}: #{e[:cmd]} (dispatcher path #{e[:dispatcher_path]} differs from " \
448
+ "this file's other Plastic entries at #{majority_path})"
449
+ end
450
+ end
451
+
452
+ stale.uniq!
453
+
454
+ if stale.empty?
455
+ [check(
456
+ category: "agent_registration", name: "codex_stale_registrations", status: "pass",
457
+ message: "No stale Plastic Codex registrations found outside the expected hook set"
458
+ )]
459
+ else
460
+ [check(
461
+ category: "agent_registration", name: "codex_stale_registrations", status: "warn",
462
+ message: "#{stale.size} stale Codex registration(s) found outside the expected hook set",
463
+ details: stale, fixable: true,
464
+ fix_hint: "Re-run the Plastic installer with --codex --reinstall to purge stale entries"
465
+ )]
466
+ end
467
+ end
291
468
  # --- Check category 2: Conventions ---
292
469
 
293
470
  # When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
@@ -446,6 +623,14 @@ class Doctor
446
623
  # flagged: validate_graph does not compute it.
447
624
  checks.concat(graph_invariant_checks(intent_dirs))
448
625
 
626
+ # node_graph - ReadySet's own view of every intent carrying a real graph.md
627
+ # (intent 336, n7): dead ends, stale done nodes, and expired running leases,
628
+ # none of which any other doctor rule surfaces. Lives here rather than
629
+ # doctor_core.rb because test/doctor_core_split_test.rb pins that file's
630
+ # boot path to exactly three project files and 781 bytes of headroom;
631
+ # ReadySet's own require chain is 65,648 bytes, far past that budget.
632
+ checks.concat(node_graph_checks(intent_dirs))
633
+
449
634
  # cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
450
635
  # `store:id` ref against the FULL store family via the relocation map
451
636
  # (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
@@ -611,7 +796,14 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
611
796
  # reported as a fixable warn (backfilled_complete). Its exclusions go to their OWN
612
797
  # bucket so savepoint_operational's consumed/dead-row bookkeeping above never sees them.
613
798
  backfill_gaps = %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
614
- backfill_gaps << "actions/" unless Savepoint.has_real_action?(dir)
799
+ # Name whichever directory the intent actually used (post-execution
800
+ # review, non-blocking 8), consistent with Savepoint.missing_for_stage:
801
+ # a nodes/ directory on disk means the intent chose the node-graph
802
+ # convention, so a gap here is a missing nodes/, never the literal
803
+ # actions/ has_real_action? no longer implies.
804
+ unless Savepoint.has_real_action?(dir)
805
+ backfill_gaps << (File.directory?(File.join(dir, "nodes")) ? "nodes/" : "actions/")
806
+ end
615
807
  if backfill_gaps.any?
616
808
  suppressed_backfill = excluded_rules.include?("backfilled_complete")
617
809
  target = suppressed_backfill ? findings[:excluded_backfill] : findings[:unbackfilled]
@@ -646,6 +838,9 @@ def check_done_signals(scopes: nil)
646
838
  unbackfilled = [] # spec/plan/actions gaps on terminal intents (intent 308) - repairable
647
839
  excluded_backfill = [] # backfill gaps knowingly exempted via doctor-exclusions (intent 308)
648
840
  dead_rows_by_rule = Hash.new { |h, k| h[k] = [] }
841
+ index_drift = [] # intent 337 (G4): INDEX vs. the intent ledgers, scoped to what
842
+ # savepoint_operational does not already report (row 6.7 - a silent or
843
+ # absent ledger is already IndexProjection's own exclusion, row 5.13)
649
844
 
650
845
  done_signal_stores(scopes).each do |store|
651
846
  exclusions = DoctorExclusions.load(store[:index])
@@ -654,6 +849,14 @@ def check_done_signals(scopes: nil)
654
849
  exclusion_error_paths << exclusions[:path]
655
850
  end
656
851
 
852
+ if File.exist?(store[:index])
853
+ projection = IndexProjection.analyze(store[:store_dir], index_path: store[:index])
854
+ projection[:drift].each do |row|
855
+ index_drift << "#{store[:scope]}: #{row[:id]} - INDEX says #{row[:index_status]}, " \
856
+ "the ledger's last Done line says #{row[:ledger_status]}"
857
+ end
858
+ end
859
+
657
860
  consumed = { "savepoint_operational" => [], "backfilled_complete" => [] }
658
861
  # `known_ids` (post-review fix): every intent id with a REAL DIRECTORY in this store, scanned
659
862
  # directly from disk - independent of INDEX.md. An id can have a directory on disk without
@@ -898,6 +1101,30 @@ def check_done_signals(scopes: nil)
898
1101
  )
899
1102
  end
900
1103
 
1104
+ # index_ledger_drift (intent 337, G4): a REAL terminal ledger line (Done
1105
+ # delivered or abandoned) that disagrees with the INDEX section an intent
1106
+ # currently sits in. Never fires for a silent or absent ledger - that gap
1107
+ # is savepoint_operational's own concern, and IndexProjection's own drift
1108
+ # computation already excludes it (row 5.13), so no id is ever double
1109
+ # reported across the two checks (row 6.7). Warn+fixable: `index-projection
1110
+ # <store_root> --write` is the repair, never invented here.
1111
+ if index_drift.empty?
1112
+ checks << check(
1113
+ category: "done_signals", name: "index_ledger_drift", status: "pass",
1114
+ message: "No INDEX section disagrees with an intent's own terminal ledger line"
1115
+ )
1116
+ else
1117
+ checks << check(
1118
+ category: "done_signals", name: "index_ledger_drift", status: "warn",
1119
+ message: "#{index_drift.size} intent#{index_drift.size == 1 ? "" : "s"} whose terminal " \
1120
+ "ledger line disagrees with the INDEX section it currently sits in",
1121
+ details: index_drift, fixable: true,
1122
+ fix_hint: "Reconcile via `index-projection <store_root> --write` (the ledger wins; only the " \
1123
+ "conflicting entries move, every other byte of INDEX.md, including ## Clusters and " \
1124
+ "## Relocated, is left untouched)."
1125
+ )
1126
+ end
1127
+
901
1128
  checks
902
1129
  end
903
1130
 
@@ -1000,9 +1227,12 @@ end
1000
1227
  #
1001
1228
  # Doctor's fourth check scope, invoked by `--intent <id>`. Never a store-wide sweep:
1002
1229
  # resolves exactly one intent directory (mirrors scripts/end-intent's resolve_intent_dir /
1003
- # scripts/project-links's --intent disambiguation) and returns five checks, four
1004
- # FAIL-severity, one WARN-severity (intent_savepoint_truthful stays WARN per intent 134's
1005
- # binding advisory-only ruling; see spec.md D2/D8 - do NOT escalate it to FAIL).
1230
+ # scripts/project-links's --intent disambiguation) and returns seven checks, four
1231
+ # FAIL-severity, three WARN-severity: intent_savepoint_truthful stays WARN per intent 134's
1232
+ # binding advisory-only ruling (see spec.md D2/D8 - do NOT escalate it to FAIL),
1233
+ # intent_ticks_lag is WARN-only per intent 329's ruling that a lagging tick warns rather
1234
+ # than blocks, and intent_reports_printed is WARN-only per intent 331f (never re-litigates
1235
+ # a ledger predating the Report kind).
1006
1236
  def check_intent_end(id, store: nil, disposition: nil)
1007
1237
  intent_dir, scope = resolve_single_intent_dir(id, store: store)
1008
1238
  unless intent_dir
@@ -1024,6 +1254,8 @@ end
1024
1254
  intent_structure_check(intent_dir),
1025
1255
  intent_lifecycle_artifacts_check(intent_dir, disposition),
1026
1256
  intent_checklist_complete_check(intent_dir),
1257
+ intent_ticks_lag_check(intent_dir),
1258
+ intent_reports_printed_check(intent_dir),
1027
1259
  intent_links_projection_check_for(id, scope),
1028
1260
  intent_savepoint_truthful_check(intent_dir, index_path: index_path),
1029
1261
  ]
@@ -1102,6 +1334,80 @@ end
1102
1334
  end
1103
1335
  end
1104
1336
 
1337
+ # Intent 329: the checklist is the only input to the state screen's Progress bar, so an
1338
+ # intent whose commit ledger has entries and whose checklist has no ticked item is
1339
+ # reporting 0 progress on work that already landed. Advisory only (WARN), like
1340
+ # intent_savepoint_truthful: a lagging tick is a lead's review finding, never a machine
1341
+ # refusal. The commit count comes from this intent's own savepoint.md Commit ledger
1342
+ # (intent 317 D17), never from git: git's detected base is the repo default branch, which
1343
+ # is not the base a 2.0 intent branch forks from.
1344
+ def intent_ticks_lag_check(intent_dir)
1345
+ items = IntentScreen.checklist_items(intent_dir)
1346
+ if items.empty?
1347
+ return check(category: "intent_end", name: "intent_ticks_lag", status: "pass",
1348
+ message: "n/a: checklist.md has no items to tick")
1349
+ end
1350
+
1351
+ ticked = items.count { |i| i[:done] }
1352
+ commits = savepoint_commit_count(intent_dir)
1353
+
1354
+ if commits.positive? && ticked.zero?
1355
+ check(category: "intent_end", name: "intent_ticks_lag", status: "warn",
1356
+ message: "ticks lag the branch: #{commits} commit(s) recorded, " \
1357
+ "0 of #{items.size} checklist items ticked")
1358
+ else
1359
+ check(category: "intent_end", name: "intent_ticks_lag", status: "pass",
1360
+ message: "#{ticked} of #{items.size} ticked, #{commits} commit(s) recorded")
1361
+ end
1362
+ end
1363
+
1364
+ # Intent 331f: every skill that shows state during Exec is bound to print a report screen and
1365
+ # log a `Report` savepoint line (`savepoint-note --kind Report`). A commit landed with no
1366
+ # Report line anywhere in the ledger is the exact defect this check exists to catch - the
1367
+ # delivery ran but nothing on disk proves a screen was ever printed. WARN-only, like
1368
+ # intent_ticks_lag: a lead's review finding, never a machine refusal. R6: never re-litigate
1369
+ # history - an intent whose newest Commit line predates the day the Report kind shipped
1370
+ # (Savepoint::REPORT_KIND_SINCE) passes, so every pre-existing ledger keeps passing
1371
+ # `doctor --intent` (which exits 1 on an overall warn).
1372
+ def intent_reports_printed_check(intent_dir)
1373
+ path = File.join(intent_dir, "savepoint.md")
1374
+ lines = File.exist?(path) ? File.readlines(path) : []
1375
+ kinds = lines.filter_map { |l| l.strip.match(IntentScreen::SAVEPOINT_RE) }
1376
+
1377
+ commit_timestamps = kinds.select { |m| m[2] == "Commit" }.map { |m| m[1] }
1378
+ if commit_timestamps.empty?
1379
+ return check(category: "intent_end", name: "intent_reports_printed", status: "pass",
1380
+ message: "n/a: no commits recorded")
1381
+ end
1382
+
1383
+ if kinds.any? { |m| m[2] == "Report" }
1384
+ return check(category: "intent_end", name: "intent_reports_printed", status: "pass",
1385
+ message: "a Report line is recorded")
1386
+ end
1387
+
1388
+ newest_commit_date = commit_timestamps.max[0, 10]
1389
+ if newest_commit_date < Savepoint::REPORT_KIND_SINCE
1390
+ return check(category: "intent_end", name: "intent_reports_printed", status: "pass",
1391
+ message: "n/a: newest commit (#{newest_commit_date}) predates the Report " \
1392
+ "kind (#{Savepoint::REPORT_KIND_SINCE})")
1393
+ end
1394
+
1395
+ check(category: "intent_end", name: "intent_reports_printed", status: "warn",
1396
+ message: "commits are recorded and no Report line exists")
1397
+ end
1398
+
1399
+ # Count `Commit` lines in the intent's savepoint ledger, through the one regex that parses
1400
+ # a savepoint line (IntentScreen::SAVEPOINT_RE; field 2 is the kind).
1401
+ def savepoint_commit_count(intent_dir)
1402
+ path = File.join(intent_dir, "savepoint.md")
1403
+ return 0 unless File.exist?(path)
1404
+
1405
+ File.readlines(path).count do |line|
1406
+ m = line.strip.match(IntentScreen::SAVEPOINT_RE)
1407
+ m && m[2] == "Commit"
1408
+ end
1409
+ end
1410
+
1105
1411
  def intent_links_projection_check_for(id, scope)
1106
1412
  ctx = build_links_projection_context
1107
1413
  node = ctx[:intents].find { |n| n[:id] == id && n[:scope] == scope }
@@ -1208,20 +1514,37 @@ end
1208
1514
  expected_pair = Savepoint.savepoint_milestone(intent_dir, File.basename(Savepoint.intent_file(intent_dir)))
1209
1515
 
1210
1516
  phantoms = Savepoint.savepoint_phantom_lines(intent_dir)
1517
+ # Intent 335 (spec C2/C5): a torn or unattributed node/Intent transition
1518
+ # line has no reader anywhere else in the tree, so this is where it
1519
+ # surfaces. NodeLedger.anomalies never touches a stage line (its own
1520
+ # transition-candidate gate excludes them), so this is purely additive
1521
+ # beside the phantom check above.
1522
+ transition_anomalies = NodeLedger.anomalies(savepoint)
1211
1523
  problems = []
1212
1524
  problems << "born line #{born_pair.inspect} does not match the expected #{expected_pair.inspect}" \
1213
1525
  if born_pair != expected_pair
1214
1526
  problems << "#{phantoms.size} phantom savepoint line(s): " \
1215
1527
  "#{phantoms.map { |l, r| "#{l} (#{r})" }.join("; ")}" if phantoms.any?
1528
+ problems << "#{transition_anomalies.size} torn/unattributed transition line(s): " \
1529
+ "#{transition_anomalies.map { |a| "#{a[:line]} (#{a[:reason]})" }.join("; ")}" \
1530
+ if transition_anomalies.any?
1216
1531
 
1217
1532
  if problems.empty?
1218
1533
  check(category: "intent_end", name: "intent_savepoint_truthful", status: "pass",
1219
1534
  message: "Savepoint born line and phantom-line state are truthful")
1220
1535
  else
1536
+ # A torn or unattributed transition line's CONTENT cannot be repaired by
1537
+ # a rebuild (rebuild_savepoint preserves it verbatim, spec D13), unlike a
1538
+ # born-line mismatch or a stage phantom, which rebuild does fix. Say so
1539
+ # explicitly rather than pointing at a fix that will not fix it.
1540
+ fix_hint = "Rebuild the ledger via Savepoint.rebuild_savepoint"
1541
+ fix_hint += "; note: rebuild preserves a torn or unattributed transition line " \
1542
+ "verbatim and does not repair one - hand-edit it or accept the finding" \
1543
+ if transition_anomalies.any?
1221
1544
  check(category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
1222
1545
  message: "#{problems.size} savepoint truthfulness issue(s) (advisory, never blocking)",
1223
1546
  details: problems,
1224
- fixable: true, fix_hint: "Rebuild the ledger via Savepoint.rebuild_savepoint")
1547
+ fixable: true, fix_hint: fix_hint)
1225
1548
  end
1226
1549
  end
1227
1550
 
@@ -1367,6 +1690,90 @@ end
1367
1690
  end
1368
1691
  end
1369
1692
 
1693
+ # ReadySet over every intent carrying a real graph.md (intent 336, n7):
1694
+ # dead ends, stale done nodes, and expired running leases. An intent with
1695
+ # no real graph.md is skipped entirely (never touches ReadySet); a
1696
+ # malformed or cyclic one is reported by name, never raised out of doctor.
1697
+ def node_graph_checks(intent_dirs)
1698
+ dead_ends = []
1699
+ stale = []
1700
+ expired = []
1701
+ malformed = []
1702
+
1703
+ intent_dirs.each do |d|
1704
+ graph_path = File.join(d[:path], "graph.md")
1705
+ next unless Savepoint.stage_file_present?(graph_path)
1706
+
1707
+ analysis = ReadySet.analyze(d[:path])
1708
+ unless analysis[:ok]
1709
+ malformed << "#{d[:name]}: #{analysis[:errors].join('; ')}"
1710
+ next
1711
+ end
1712
+
1713
+ savepoint_path = File.join(d[:path], "savepoint.md")
1714
+ analysis[:nodes].each do |id, view|
1715
+ dead_ends << "#{d[:name]}/#{id}" if view[:dead_end]
1716
+ stale << "#{d[:name]}/#{id}" if view[:stale]
1717
+ expired << "#{d[:name]}/#{id}" if view[:state] == "running" && expired_running_lease?(savepoint_path, id)
1718
+ end
1719
+ end
1720
+
1721
+ [
1722
+ node_graph_finding_check(
1723
+ "node_graph_dead_ends", dead_ends,
1724
+ "No node waits on a need that resolves to superseded or abandoned",
1725
+ "Re-plan or supersede the dead-end node's need chain, or abandon the node itself"
1726
+ ),
1727
+ node_graph_finding_check(
1728
+ "node_graph_stale_done", stale,
1729
+ "No done node rests on a need superseded after it finished",
1730
+ "Re-verify the stale node against the superseding work, or record the staleness in revisions.md"
1731
+ ),
1732
+ node_graph_finding_check(
1733
+ "node_graph_expired_running_lease", expired,
1734
+ "No running node's lease has expired unreclaimed",
1735
+ "Run `node-transition <intent_dir> --node <id> --state reclaimed --field holder=<h> " \
1736
+ "--field expired=<iso>` to sweep it"
1737
+ ),
1738
+ node_graph_finding_check(
1739
+ "node_graph_malformed", malformed,
1740
+ "Every intent's graph.md parses cleanly",
1741
+ "Fix the malformed graph.md or nodes/ file named in the details"
1742
+ ),
1743
+ ]
1744
+ end
1745
+
1746
+ # True iff `id`'s last running line in the ledger at `savepoint_path` carries
1747
+ # an `expires=` strictly in the past. An unparseable or absent expires=
1748
+ # never counts as expired (fail milder than the bug).
1749
+ def expired_running_lease?(savepoint_path, id, now: Time.now)
1750
+ entry = NodeLedger.last_running(savepoint_path, id)
1751
+ return false unless entry
1752
+
1753
+ expires_raw = (entry[:fields] || {})["expires"]
1754
+ expiry = begin
1755
+ expires_raw && Time.iso8601(expires_raw)
1756
+ rescue ArgumentError
1757
+ nil
1758
+ end
1759
+ !!(expiry && now > expiry)
1760
+ end
1761
+
1762
+ # One node-graph check: pass when `findings` is empty, otherwise warn
1763
+ # (never fail, matching graph_finding_check's own precedent - an existing
1764
+ # store never turns red on an advisory graph finding).
1765
+ def node_graph_finding_check(name, findings, pass_message, fix_hint)
1766
+ if findings.empty?
1767
+ check(category: "conventions", name: name, status: "pass", message: pass_message)
1768
+ else
1769
+ check(
1770
+ category: "conventions", name: name, status: "warn",
1771
+ message: "#{findings.size} #{name} violation(s)",
1772
+ details: findings, fixable: false, fix_hint: fix_hint
1773
+ )
1774
+ end
1775
+ end
1776
+
1370
1777
  # --- Check category 3: Agent registration ---
1371
1778
 
1372
1779
 
@@ -2227,6 +2634,240 @@ end
2227
2634
  end
2228
2635
  end
2229
2636
 
2637
+ # --- Check category: display (intent 331e, D1) ---
2638
+ #
2639
+ # Three of the four `display` checks live HERE, not in doctor_core.rb: they
2640
+ # need Open3/Timeout to spawn the installed hook as a real subprocess, and
2641
+ # that must never attach to the SessionStart boot path
2642
+ # (test/doctor_core_split_test.rb T2 pins doctor_core.rb's require set
2643
+ # exactly). check_display_registration (the fourth, --core-scoped) lives in
2644
+ # doctor_core.rb instead, alongside display_hook_launcher_name, which this
2645
+ # file's check_display_paints reuses to name the SAME installed launcher.
2646
+
2647
+ # The ambient defeater active for THIS invocation, or nil. `no_color` is
2648
+ # DI'd (default ENV["NO_COLOR"]) rather than read deep inside this method,
2649
+ # so a test can force it on or off without touching the real process
2650
+ # environment. Shared by check_display_paints (a defeater turns a
2651
+ # would-be fail into a pass, R3) and check_display_not_defeated (a
2652
+ # defeater is what it warns about).
2653
+ def active_display_defeater(no_color: ENV["NO_COLOR"])
2654
+ return "NO_COLOR" unless no_color.to_s.empty?
2655
+
2656
+ cfg = load_yaml_safe(File.join(plastic_home, "config.yml"))
2657
+ display_cfg = cfg.is_a?(Hash) ? cfg["display"] : nil
2658
+ return "display.ansi_screen: false in config.yml" if display_cfg.is_a?(Hash) &&
2659
+ display_cfg.fetch("ansi_screen", true) == false
2660
+
2661
+ nil
2662
+ end
2663
+
2664
+ # The shipped fixture's path: plastic_home's own templates/ first (a real
2665
+ # install), the package's own templates/ otherwise (running from a repo
2666
+ # checkout, or an install whose templates/ predates this fixture). Neither
2667
+ # existing means no fixture at all.
2668
+ def display_fixture_path(package_root: PACKAGE_ROOT)
2669
+ [File.join(plastic_home, "templates", "display-fixture.md"),
2670
+ File.join(package_root, "templates", "display-fixture.md")].find { |p| File.file?(p) }
2671
+ end
2672
+
2673
+ # The fixture's replayable text: its header comment (see
2674
+ # templates/display-fixture.md) is for a human reading the file on disk,
2675
+ # never sent through the hook.
2676
+ def display_fixture_text(path)
2677
+ File.read(path).sub(/\A<!--.*?-->\n\n?/m, "")
2678
+ end
2679
+
2680
+ # display_hook_paints (D1, R1/R2/R3): replays the shipped fixture through
2681
+ # the INSTALLED launcher (`<agent_dir>/hooks/plastic-message-display`,
2682
+ # never this package's own hooks/message-display. That is R1's whole point: an
2683
+ # installed launcher can predate the package's, and replaying the wrong
2684
+ # one reports pass while the real stack is stale) and expects a painted
2685
+ # (ANSI) screen back.
2686
+ #
2687
+ # A known defeater active (NO_COLOR, or display.ansi_screen: false) turns
2688
+ # a no-SGR result into a PASS, naming the defeater and noting it reflects
2689
+ # this invocation's own environment (R3). display_not_defeated is what
2690
+ # warns about a defeater; this check never fails because of one.
2691
+ def check_display_paints(agent_key, no_color: ENV["NO_COLOR"],
2692
+ tmp_dir_factory: -> { Dir.mktmpdir("plastic-doctor-display") },
2693
+ timeout_seconds: 10, package_root: PACKAGE_ROOT)
2694
+ config = agents[agent_key]
2695
+ unless agent_key == "claude"
2696
+ return [check(
2697
+ category: "display", name: "display_hook_paints", status: "pass",
2698
+ message: "#{config[:name]} is plain by contract; no display hook to replay"
2699
+ )]
2700
+ end
2701
+
2702
+ fixture_path = display_fixture_path(package_root: package_root)
2703
+ unless fixture_path
2704
+ return [check(
2705
+ category: "display", name: "display_hook_paints", status: "fail",
2706
+ message: "Shipped display fixture is missing (templates/display-fixture.md)",
2707
+ fixable: false
2708
+ )]
2709
+ end
2710
+
2711
+ agent_dir = config[:dir]
2712
+ launcher_path = File.join(agent_dir, "hooks", display_hook_launcher_name)
2713
+ unless File.file?(launcher_path) && File.executable?(launcher_path)
2714
+ return [check(
2715
+ category: "display", name: "display_hook_paints", status: "fail",
2716
+ message: "Installed launcher #{tilde(launcher_path)} is missing or not executable; cannot replay",
2717
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
2718
+ )]
2719
+ end
2720
+
2721
+ text = display_fixture_text(fixture_path)
2722
+ tmp_dir = tmp_dir_factory.call
2723
+ outs =
2724
+ begin
2725
+ HookReplay.replay(hook_path: launcher_path, tmp_root: tmp_dir, text: text,
2726
+ env: { "PLASTIC_HOME" => plastic_home }, timeout: timeout_seconds)
2727
+ rescue StandardError => e
2728
+ # Process.spawn (inside HookReplay) can raise before a pid ever
2729
+ # exists: a permissions race, or a launcher that vanishes between the
2730
+ # executable? check above and the spawn, or any other unexpected
2731
+ # error. Unlike this codebase's defensive style elsewhere
2732
+ # (read_json_safe, load_yaml_safe), nothing here degraded that into
2733
+ # a clean check result, so an unlucky replay crashed the entire
2734
+ # doctor run instead of failing just this one check (intent 331e,
2735
+ # F6). `return` still runs the `ensure` below before unwinding.
2736
+ return [check(
2737
+ category: "display", name: "display_hook_paints", status: "fail",
2738
+ message: "Replaying the installed launcher raised #{e.class}: #{e.message}",
2739
+ fixable: false
2740
+ )]
2741
+ ensure
2742
+ FileUtils.remove_entry(tmp_dir) if tmp_dir && File.exist?(tmp_dir)
2743
+ end
2744
+
2745
+ if HookReplay.timed_out?(outs)
2746
+ return [check(
2747
+ category: "display", name: "display_hook_paints", status: "fail",
2748
+ message: "Replaying the installed launcher timed out after #{timeout_seconds}s; painting could not be verified",
2749
+ fixable: false
2750
+ )]
2751
+ end
2752
+
2753
+ defeater = active_display_defeater(no_color: no_color)
2754
+ if defeater
2755
+ return [check(
2756
+ category: "display", name: "display_hook_paints", status: "pass",
2757
+ message: "Painting is defeated by #{defeater} for this invocation; the replay's plain " \
2758
+ "output reflects that setting, not a broken hook"
2759
+ )]
2760
+ end
2761
+
2762
+ content = HookReplay.final_display_content(outs)
2763
+ if content.to_s.include?("\e[")
2764
+ [check(
2765
+ category: "display", name: "display_hook_paints", status: "pass",
2766
+ message: "The installed hook returned a painted (ANSI) screen"
2767
+ )]
2768
+ else
2769
+ [check(
2770
+ category: "display", name: "display_hook_paints", status: "fail",
2771
+ message: "The installed hook returned no ANSI escape sequence; painting may be broken",
2772
+ fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
2773
+ )]
2774
+ end
2775
+ end
2776
+
2777
+ # display_not_defeated (D1, R3/R4): warns, never fails, on each active
2778
+ # defeater, naming the setting and its effect. Every result, pass or warn alike,
2779
+ # names the verbose transcript view (Ctrl+O, R4): it redraws every
2780
+ # screen as plain tables too, but it has no on-disk setting doctor can
2781
+ # read, so its absence from these warnings is never proof that view paints.
2782
+ def check_display_not_defeated(agent_key, no_color: ENV["NO_COLOR"])
2783
+ config = agents[agent_key]
2784
+ unless agent_key == "claude"
2785
+ return [check(
2786
+ category: "display", name: "display_not_defeated", status: "pass",
2787
+ message: "#{config[:name]} is plain by contract; no display defeaters apply"
2788
+ )]
2789
+ end
2790
+
2791
+ warnings = []
2792
+ warnings << "NO_COLOR is set: forces every screen to plain text for this invocation" unless no_color.to_s.empty?
2793
+
2794
+ cfg = load_yaml_safe(File.join(plastic_home, "config.yml"))
2795
+ display_cfg = cfg.is_a?(Hash) ? cfg["display"] : nil
2796
+ if display_cfg.is_a?(Hash) && display_cfg.fetch("ansi_screen", true) == false
2797
+ warnings << "config.yml sets display.ansi_screen: false: forces every screen to plain text"
2798
+ end
2799
+
2800
+ settings = read_json_safe(File.join(config[:dir], "settings.json"))
2801
+ if settings.is_a?(Hash) && settings["verbose"] == true
2802
+ warnings << "settings.json sets verbose: true: the verbose transcript view (Ctrl+O) redraws every screen as plain tables"
2803
+ end
2804
+
2805
+ transcript_note = "The Ctrl+O verbose transcript view also redraws every screen as plain " \
2806
+ "tables and has no on-disk setting; its absence above is never proof that view paints."
2807
+
2808
+ if warnings.empty?
2809
+ [check(
2810
+ category: "display", name: "display_not_defeated", status: "pass",
2811
+ message: "No known display defeaters active", details: [transcript_note]
2812
+ )]
2813
+ else
2814
+ [check(
2815
+ category: "display", name: "display_not_defeated", status: "warn",
2816
+ message: "#{warnings.size} display defeater(s) active",
2817
+ details: warnings + [transcript_note]
2818
+ )]
2819
+ end
2820
+ end
2821
+
2822
+ # display_surfaces_documented (D1/D4): the harness-adapters doc names the
2823
+ # three surface classes. Reads the package's own shipped doc: static
2824
+ # content, not a runtime path, the same shape as check_skill_lint reading the
2825
+ # package's own skills/ tree.
2826
+ #
2827
+ # `docs/` ships in NEITHER package.json's `files` list NOR
2828
+ # InstallerCore's manifest (grep confirms zero references), so on every
2829
+ # real install `package_root` resolves to a `~/.plastic` that has no
2830
+ # `docs/` tree at all; only a repo checkout carries it. Absence of the
2831
+ # doc there is therefore not a defect to report; it is this install
2832
+ # having nothing to verify, the same skip-as-pass vocabulary D3 and R3
2833
+ # already use elsewhere in this category. This check fails only when the
2834
+ # doc DOES exist (a repo checkout) but has rotted: no `## Surfaces`
2835
+ # section, or one missing a required literal.
2836
+ def check_display_surfaces_documented(package_root: PACKAGE_ROOT)
2837
+ doc_path = File.join(package_root, "docs", "reference", "harness-adapters.md")
2838
+
2839
+ unless File.file?(doc_path)
2840
+ return [check(
2841
+ category: "display", name: "display_surfaces_documented", status: "pass",
2842
+ message: "Reference docs are not shipped to this install (#{tilde(doc_path)} absent); " \
2843
+ "nothing to verify"
2844
+ )]
2845
+ end
2846
+
2847
+ content = File.read(doc_path)
2848
+ section = content[/^## Surfaces\n(.*?)(?=\n## |\z)/m, 1].to_s
2849
+
2850
+ required = ["Claude Code normal view", "agents view", "Codex", "claude -p", "verbose transcript view"]
2851
+ missing = required.reject { |literal| section.include?(literal) }
2852
+
2853
+ if section.empty?
2854
+ [check(
2855
+ category: "display", name: "display_surfaces_documented", status: "fail",
2856
+ message: "docs/reference/harness-adapters.md has no ## Surfaces section", fixable: false
2857
+ )]
2858
+ elsif missing.empty?
2859
+ [check(
2860
+ category: "display", name: "display_surfaces_documented", status: "pass",
2861
+ message: "harness-adapters.md documents every display surface class"
2862
+ )]
2863
+ else
2864
+ [check(
2865
+ category: "display", name: "display_surfaces_documented", status: "fail",
2866
+ message: "## Surfaces section is missing: #{missing.join(', ')}", details: missing, fixable: false
2867
+ )]
2868
+ end
2869
+ end
2870
+
2230
2871
  # --- Run all checks ---
2231
2872
 
2232
2873
  def run_checks(agent_key)
@@ -2235,6 +2876,8 @@ end
2235
2876
  all_checks += check_conventions(scopes: ["global"])
2236
2877
  all_checks += check_agent_registration(agent_key)
2237
2878
  all_checks += check_core_files(agent_key)
2879
+ all_checks += check_harness_versions
2880
+ all_checks += check_codex_stale_registrations
2238
2881
  all_checks += check_deprecations
2239
2882
  all_checks += check_config_asks(agent_key)
2240
2883
  all_checks += check_qmd
@@ -2243,6 +2886,10 @@ end
2243
2886
  all_checks += check_session_ledger(scopes: ["global"])
2244
2887
  all_checks += check_skill_lint
2245
2888
  all_checks += check_install_integrity
2889
+ all_checks += check_display_registration(agent_key)
2890
+ all_checks += check_display_paints(agent_key)
2891
+ all_checks += check_display_not_defeated(agent_key)
2892
+ all_checks += check_display_surfaces_documented
2246
2893
 
2247
2894
  summarize(all_checks, agent_key)
2248
2895
  end