@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21
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/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- 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 +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -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 +11 -0
- 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
|
-
|
|
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
|
|
1004
|
-
# FAIL-severity,
|
|
1005
|
-
# binding advisory-only ruling
|
|
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:
|
|
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
|