@zalom/plastic 1.7.0 → 1.8.0
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/PLASTIC.md +20 -3
- package/package.json +1 -1
- package/scripts/doctor.rb +603 -183
- package/scripts/end-intent +65 -22
- package/scripts/lib/installer_core.rb +1 -1
- package/scripts/lib/maintenance_git.rb +2 -1
- package/scripts/lib/outcome_guard.rb +38 -0
- package/scripts/maintenance-run +56 -2
- package/skills/auto/SKILL.md +5 -4
- package/skills/doctor/SKILL.md +50 -18
- package/skills/intent-ending/SKILL.md +16 -6
- package/templates/agents.md +8 -0
- package/scripts/lib/legacy_bookend_amnesty.rb +0 -35
package/scripts/doctor.rb
CHANGED
|
@@ -26,9 +26,10 @@ require_relative "lib/hook_registry"
|
|
|
26
26
|
require_relative "lib/lock"
|
|
27
27
|
require_relative "lib/bridge"
|
|
28
28
|
require_relative "lib/agent_models"
|
|
29
|
-
require_relative "lib/
|
|
29
|
+
require_relative "lib/outcome_guard"
|
|
30
30
|
require_relative "lib/skill_lint"
|
|
31
31
|
require_relative "lib/config_asks"
|
|
32
|
+
require_relative "lib/power_tools"
|
|
32
33
|
|
|
33
34
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
34
35
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -48,11 +49,6 @@ class Doctor
|
|
|
48
49
|
# (intent 60). Alias it here so the two can never drift.
|
|
49
50
|
REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
|
|
50
51
|
|
|
51
|
-
# Single source of truth for the pre-161 legacy Done-bookend amnesty list
|
|
52
|
-
# lives in LegacyBookendAmnesty (intent 170a). Alias it here so the two can
|
|
53
|
-
# never drift.
|
|
54
|
-
LEGACY_BOOKEND_AMNESTY = LegacyBookendAmnesty::LIST
|
|
55
|
-
|
|
56
52
|
CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
|
|
57
53
|
|
|
58
54
|
# Launchers the installer places in the agent's hooks dir that are NOT hooks
|
|
@@ -93,10 +89,9 @@ class Doctor
|
|
|
93
89
|
end
|
|
94
90
|
|
|
95
91
|
def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS,
|
|
96
|
-
|
|
92
|
+
runner: Doctor.default_runner)
|
|
97
93
|
@plastic_home = plastic_home
|
|
98
94
|
@agents = agents
|
|
99
|
-
@bookend_amnesty = bookend_amnesty
|
|
100
95
|
@runner = runner
|
|
101
96
|
end
|
|
102
97
|
|
|
@@ -112,6 +107,8 @@ class Doctor
|
|
|
112
107
|
# :global — --store global
|
|
113
108
|
# "<slug>" — --store <slug> (a single project)
|
|
114
109
|
store = nil
|
|
110
|
+
intent = nil
|
|
111
|
+
disposition = nil
|
|
115
112
|
|
|
116
113
|
i = 0
|
|
117
114
|
while i < argv.length
|
|
@@ -127,6 +124,12 @@ class Doctor
|
|
|
127
124
|
when "--core"
|
|
128
125
|
core = true
|
|
129
126
|
i += 1
|
|
127
|
+
when "--intent"
|
|
128
|
+
intent = argv[i + 1]
|
|
129
|
+
i += 2
|
|
130
|
+
when "--disposition"
|
|
131
|
+
disposition = argv[i + 1]
|
|
132
|
+
i += 2
|
|
130
133
|
when "--store"
|
|
131
134
|
nxt = argv[i + 1]
|
|
132
135
|
if nxt && !nxt.start_with?("-")
|
|
@@ -144,7 +147,7 @@ class Doctor
|
|
|
144
147
|
end
|
|
145
148
|
end
|
|
146
149
|
|
|
147
|
-
{ agent: agent, help: help, core: core, store: store }
|
|
150
|
+
{ agent: agent, help: help, core: core, store: store, intent: intent, disposition: disposition }
|
|
148
151
|
end
|
|
149
152
|
|
|
150
153
|
def show_help
|
|
@@ -163,6 +166,11 @@ class Doctor
|
|
|
163
166
|
--store [WHICH] Run only the store/conventions checks. WHICH may be:
|
|
164
167
|
global (global store only), a project slug (that project
|
|
165
168
|
only), or omitted (all stores). 3-state pass/warn/fail.
|
|
169
|
+
--intent ID Per-intent structure gate at intent-end (intent 222): one
|
|
170
|
+
closing intent only, never a store sweep. Pair with
|
|
171
|
+
--store <key> to disambiguate an id that collides across
|
|
172
|
+
stores, and --disposition delivered|abandoned to fold in
|
|
173
|
+
the outcome.md disposition check. 3-state pass/warn/fail.
|
|
166
174
|
-h, --help Show this help
|
|
167
175
|
|
|
168
176
|
Output:
|
|
@@ -366,6 +374,45 @@ class Doctor
|
|
|
366
374
|
checks
|
|
367
375
|
end
|
|
368
376
|
|
|
377
|
+
# --- Check category: global store availability (core-only, D1) ---
|
|
378
|
+
#
|
|
379
|
+
# Narrow, core-appropriate version of check_global_store: presence/readability only, zero
|
|
380
|
+
# content scanning. check_global_store (above) is store/full-scope and includes
|
|
381
|
+
# orphaned_intents / ghost_references, both explicitly forbidden at core by D1.
|
|
382
|
+
def check_global_store_available
|
|
383
|
+
checks = []
|
|
384
|
+
|
|
385
|
+
if File.directory?(plastic_home)
|
|
386
|
+
checks << check(
|
|
387
|
+
category: "global_store", name: "global_store_reachable", status: "pass",
|
|
388
|
+
message: "Global store directory reachable at #{tilde(plastic_home)}"
|
|
389
|
+
)
|
|
390
|
+
else
|
|
391
|
+
checks << check(
|
|
392
|
+
category: "global_store", name: "global_store_reachable", status: "fail",
|
|
393
|
+
message: "Global store directory not found at #{tilde(plastic_home)}",
|
|
394
|
+
fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
|
|
395
|
+
)
|
|
396
|
+
return checks
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
index_path = File.join(plastic_home, "INDEX.md")
|
|
400
|
+
if File.exist?(index_path)
|
|
401
|
+
checks << check(
|
|
402
|
+
category: "global_store", name: "global_index_reachable", status: "pass",
|
|
403
|
+
message: "INDEX.md exists"
|
|
404
|
+
)
|
|
405
|
+
else
|
|
406
|
+
checks << check(
|
|
407
|
+
category: "global_store", name: "global_index_reachable", status: "fail",
|
|
408
|
+
message: "INDEX.md not found at #{tilde(index_path)}",
|
|
409
|
+
fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
checks
|
|
414
|
+
end
|
|
415
|
+
|
|
369
416
|
# --- Check category 2: Conventions ---
|
|
370
417
|
|
|
371
418
|
# When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
|
|
@@ -608,172 +655,205 @@ class Doctor
|
|
|
608
655
|
stores
|
|
609
656
|
end
|
|
610
657
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
658
|
+
# Per-directory done-signal findings for ONE intent dir (intent 222): the shared unit both
|
|
659
|
+
# check_done_signals's store-wide loop (211's territory, unchanged severities) and the new
|
|
660
|
+
# per-intent check (check_intent_end) call, so the two surfaces can never independently
|
|
661
|
+
# drift on what counts as a phantom line or a completeness gap. Returns
|
|
662
|
+
# {conflict:, phantom:, gap:, stalled:} where conflict/phantom/stalled are nil or a finding
|
|
663
|
+
# string, and gap is an ARRAY (0, 1, or 2 strings): the original inline code pushed the
|
|
664
|
+
# "outcome missing" gap and the "audit echo missing" gap as two SEPARATE, independent `if`
|
|
665
|
+
# blocks (never elsif), so a single terminal dir missing BOTH can legitimately contribute
|
|
666
|
+
# two distinct gap strings in the same pass; collapsing that into one nilable field would
|
|
667
|
+
# silently drop one of the two on a dir where both are true (verified against
|
|
668
|
+
# test/doctor_done_signals_test.rb's fixtures before this extraction, per plan.md Step 3).
|
|
669
|
+
def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:)
|
|
670
|
+
outcome = File.join(dir, "outcome.md")
|
|
671
|
+
outcome_real = Bridge.stage_file_present?(outcome)
|
|
672
|
+
findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], stalled: nil }
|
|
673
|
+
|
|
674
|
+
# HARD conflict: the deliverable exists but INDEX still says Active. This
|
|
675
|
+
# is the one true INDEX-wins disagreement, so it stays a fail.
|
|
676
|
+
if active && outcome_real
|
|
677
|
+
findings[:conflict] = "#{label}: outcome.md is real but the intent is still under ## Active " \
|
|
678
|
+
"(INDEX is canonical - move it to its terminal section or revert outcome.md)"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Savepoint truthfulness (advisory, never fail; intent 134). Terminal phantom lines are
|
|
682
|
+
# ALWAYS report-only (immutable history), with no suppression by id or scope (intent 211:
|
|
683
|
+
# the generic replacement for the frozen 170a amnesty list is simply "never suppress" -
|
|
684
|
+
# the message already labels a terminal phantom report-only, so dropping the id-based
|
|
685
|
+
# suppression is strictly more general and needs no per-store state at all).
|
|
686
|
+
phantom_lines = Bridge.savepoint_phantom_lines(dir)
|
|
687
|
+
if phantom_lines.any?
|
|
688
|
+
detail = phantom_lines.map { |line, reason| "#{line} (#{reason})" }.join("; ")
|
|
689
|
+
scope_note = terminal ? "terminal in INDEX, report-only (immutable history)" : "live intent, auto-rebuildable"
|
|
690
|
+
findings[:phantom] = "#{label}: #{phantom_lines.size} phantom savepoint line(s) contradicted by " \
|
|
691
|
+
"disk, #{scope_note}: #{detail}"
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Delivery-claim gap (intent 211, 219 D6): a missing/placeholder outcome.md on a terminal
|
|
695
|
+
# intent is never fabricated, so this is unrepairable by construction. Reported informational
|
|
696
|
+
# only (signals_complete, always status "pass" - see check_done_signals).
|
|
697
|
+
if terminal && !outcome_real
|
|
698
|
+
state = File.exist?(outcome) ? "still a placeholder" : "missing"
|
|
699
|
+
findings[:gap] << "#{label}: terminal in INDEX but outcome.md is #{state} " \
|
|
700
|
+
"(delivery claim - never fabricated)"
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
if terminal
|
|
704
|
+
# Operational gap (intent 211, 219 D6): savepoint.md missing entirely (not previously
|
|
705
|
+
# checked at all) or present but missing the Done bookend - both are minimally
|
|
706
|
+
# reconstructible via maintenance-run --tool rebuild-savepoint, so this is repairable and
|
|
707
|
+
# reported as a fixable warn (savepoint_operational).
|
|
708
|
+
savepoint = File.join(dir, "savepoint.md")
|
|
709
|
+
if !File.exist?(savepoint)
|
|
710
|
+
findings[:operational_gap] << "#{label}: terminal in INDEX but savepoint.md is missing " \
|
|
711
|
+
"entirely (operational - reconstructible)"
|
|
712
|
+
elsif File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
|
|
713
|
+
findings[:operational_gap] << "#{label}: terminal in INDEX but savepoint.md has no " \
|
|
714
|
+
"`Done delivered|abandoned` line (operational - reconstructible)"
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Stalled completion: unchanged, never consulted amnesty.
|
|
718
|
+
if File.exist?(Lock.path(dir))
|
|
719
|
+
note = Lock.fresh?(dir) ? "delivery.lock still present (post-done window not closed)"
|
|
720
|
+
: "delivery.lock is present and STALE"
|
|
721
|
+
findings[:stalled] = "#{label}: #{note} - the End tail did not finish"
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
findings
|
|
726
|
+
end
|
|
634
727
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
# resurface through this advisory. The suppression is scope-qualified exactly like the
|
|
642
|
-
# audit-echo amnesty; every live intent and every non-amnestied terminal still fires.
|
|
643
|
-
phantom_lines = Bridge.savepoint_phantom_lines(dir)
|
|
644
|
-
phantom_id = dirname.split("--", 2).first
|
|
645
|
-
phantom_amnestied = terminal && @bookend_amnesty.fetch(store[:scope], []).include?(phantom_id)
|
|
646
|
-
if phantom_lines.any? && !phantom_amnestied
|
|
647
|
-
detail = phantom_lines.map { |line, reason| "#{line} (#{reason})" }.join("; ")
|
|
648
|
-
scope_note = terminal ? "terminal in INDEX, report-only (immutable history)" : "live intent, auto-rebuildable"
|
|
649
|
-
phantoms << "#{label}: #{phantom_lines.size} phantom savepoint line(s) contradicted by " \
|
|
650
|
-
"disk, #{scope_note}: #{detail}"
|
|
651
|
-
end
|
|
728
|
+
def check_done_signals(scopes: nil)
|
|
729
|
+
conflicts = []
|
|
730
|
+
gaps = [] # delivery-claim (outcome.md) gaps only - legacy, informational
|
|
731
|
+
operational_gaps = [] # savepoint gaps (missing file, or missing Done echo) - repairable
|
|
732
|
+
stalled = []
|
|
733
|
+
phantoms = []
|
|
652
734
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
state = File.exist?(outcome) ? "still a placeholder" : "missing"
|
|
658
|
-
gaps << "#{label}: terminal in INDEX but outcome.md is #{state} " \
|
|
659
|
-
"(author outcome.md with the disposition header)"
|
|
660
|
-
end
|
|
735
|
+
done_signal_stores(scopes).each do |store|
|
|
736
|
+
index_sections_by_dir(store[:index]).each do |dirname, in_sections|
|
|
737
|
+
dir = File.join(store[:store_dir], dirname)
|
|
738
|
+
next unless File.directory?(dir)
|
|
661
739
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
# `Done delivered|abandoned` line. Missing on legacy history -> advisory
|
|
666
|
-
# gap, unless the (scope, id) is on the frozen pre-161 amnesty list
|
|
667
|
-
# (intent 170a). The amnesty is scope-qualified: an id on the list for
|
|
668
|
-
# one scope does not grandfather the same id in another scope.
|
|
669
|
-
savepoint = File.join(dir, "savepoint.md")
|
|
670
|
-
if File.exist?(savepoint) && File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
|
|
671
|
-
id = dirname.split("--", 2).first
|
|
672
|
-
amnestied = @bookend_amnesty.fetch(store[:scope], []).include?(id)
|
|
673
|
-
unless amnestied
|
|
674
|
-
gaps << "#{label}: terminal in INDEX but savepoint.md has no " \
|
|
675
|
-
"`Done delivered|abandoned` line (audit echo missing)"
|
|
676
|
-
end
|
|
677
|
-
end
|
|
740
|
+
terminal = (in_sections & ["Completed", "Abandoned"]).any?
|
|
741
|
+
active = in_sections.include?("Active") && !terminal
|
|
742
|
+
label = "#{store[:scope]} store/#{dirname}"
|
|
678
743
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
744
|
+
findings = done_signal_findings_for_dir(
|
|
745
|
+
dir, label: label, scope: store[:scope], dirname: dirname, terminal: terminal, active: active
|
|
746
|
+
)
|
|
747
|
+
conflicts << findings[:conflict] if findings[:conflict]
|
|
748
|
+
phantoms << findings[:phantom] if findings[:phantom]
|
|
749
|
+
gaps.concat(findings[:gap])
|
|
750
|
+
operational_gaps.concat(findings[:operational_gap])
|
|
751
|
+
stalled << findings[:stalled] if findings[:stalled]
|
|
687
752
|
end
|
|
753
|
+
end
|
|
688
754
|
|
|
689
|
-
|
|
755
|
+
checks = []
|
|
690
756
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
end
|
|
757
|
+
# signals_agree: unchanged.
|
|
758
|
+
if conflicts.empty?
|
|
759
|
+
checks << check(
|
|
760
|
+
category: "done_signals", name: "signals_agree", status: "pass",
|
|
761
|
+
message: "No done-signal conflicts (no intent has a real outcome.md while still Active)"
|
|
762
|
+
)
|
|
763
|
+
else
|
|
764
|
+
checks << check(
|
|
765
|
+
category: "done_signals", name: "signals_agree", status: "fail",
|
|
766
|
+
message: "#{conflicts.size} done-signal conflict#{conflicts.size == 1 ? "" : "s"} " \
|
|
767
|
+
"(INDEX ## Completed/## Abandoned is canonical; a real outcome.md must not stay Active)",
|
|
768
|
+
details: conflicts, fixable: true,
|
|
769
|
+
fix_hint: "Reconcile to INDEX (canonical): move the intent to its terminal section, or revert " \
|
|
770
|
+
"outcome.md to a placeholder. Deliverable-exists but still-Active is the one done-signal " \
|
|
771
|
+
"state that must never persist."
|
|
772
|
+
)
|
|
773
|
+
end
|
|
709
774
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
end
|
|
775
|
+
# signals_complete (intent 211, 219 D6, repurposed): outcome.md delivery-claim gaps only.
|
|
776
|
+
# Never fabricated, so there is no legitimate fix to hint at - always status "pass",
|
|
777
|
+
# informational (matches check_skill_lint's advisory precedent: count + details, no
|
|
778
|
+
# fix_hint/fixable, never affects doctor's exit code).
|
|
779
|
+
if gaps.empty?
|
|
780
|
+
checks << check(
|
|
781
|
+
category: "done_signals", name: "signals_complete", status: "pass",
|
|
782
|
+
message: "Every terminal intent carries a real outcome.md"
|
|
783
|
+
)
|
|
784
|
+
else
|
|
785
|
+
checks << check(
|
|
786
|
+
category: "done_signals", name: "signals_complete", status: "pass",
|
|
787
|
+
message: "#{gaps.size} terminal intent#{gaps.size == 1 ? "" : "s"} missing a real outcome.md " \
|
|
788
|
+
"(delivery claim - never fabricated; informational only, does not affect doctor's exit code)",
|
|
789
|
+
details: gaps
|
|
790
|
+
)
|
|
791
|
+
end
|
|
728
792
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
793
|
+
# savepoint_operational (intent 211, NEW): missing savepoint.md or missing Done echo -
|
|
794
|
+
# repairable via maintenance-run --tool rebuild-savepoint, so this stays warn+fixable.
|
|
795
|
+
if operational_gaps.empty?
|
|
796
|
+
checks << check(
|
|
797
|
+
category: "done_signals", name: "savepoint_operational", status: "pass",
|
|
798
|
+
message: "No terminal intent is missing an operational savepoint.md or its Done echo"
|
|
799
|
+
)
|
|
800
|
+
else
|
|
801
|
+
checks << check(
|
|
802
|
+
category: "done_signals", name: "savepoint_operational", status: "warn",
|
|
803
|
+
message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
|
|
804
|
+
"missing an operational savepoint.md or its Done echo (reconstructible)",
|
|
805
|
+
details: operational_gaps, fixable: true,
|
|
806
|
+
fix_hint: "Reconstruct the minimal two-line started/Done echo via " \
|
|
807
|
+
"`maintenance-run --tool rebuild-savepoint --intent <id> --apply` (197-conformant: " \
|
|
808
|
+
"receipt-before-write via RevisionsWriter, one intent per invocation, owner-approval-gated)."
|
|
809
|
+
)
|
|
810
|
+
end
|
|
745
811
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
"124a manual Done-bookend repair."
|
|
763
|
-
)
|
|
764
|
-
end
|
|
812
|
+
if stalled.empty?
|
|
813
|
+
checks << check(
|
|
814
|
+
category: "done_signals", name: "stalled_completion", status: "pass",
|
|
815
|
+
message: "No stalled completions (every terminal intent released its delivery lock)"
|
|
816
|
+
)
|
|
817
|
+
else
|
|
818
|
+
checks << check(
|
|
819
|
+
category: "done_signals", name: "stalled_completion", status: "warn",
|
|
820
|
+
message: "#{stalled.size} stalled completion#{stalled.size == 1 ? "" : "s"} " \
|
|
821
|
+
"(terminal in INDEX but the End tail did not finish)",
|
|
822
|
+
details: stalled, fixable: true,
|
|
823
|
+
fix_hint: "Finish the End tail via stale-lock reclaim: run /plastic-doctor reclaim the lock, " \
|
|
824
|
+
"then complete the tail (Worktree.release -> Lock.release -> purge -> QMD reindex " \
|
|
825
|
+
"last). This FINISHES a completion; it is NOT a reactivation of a done intent."
|
|
826
|
+
)
|
|
827
|
+
end
|
|
765
828
|
|
|
766
|
-
|
|
829
|
+
# savepoint_truthful: advisory only (intent 134), never fails. No amnesty suppression left.
|
|
830
|
+
if phantoms.empty?
|
|
831
|
+
checks << check(
|
|
832
|
+
category: "done_signals", name: "savepoint_truthful", status: "pass",
|
|
833
|
+
message: "No savepoint.md lines are contradicted by disk (phantom-line check clean)"
|
|
834
|
+
)
|
|
835
|
+
else
|
|
836
|
+
checks << check(
|
|
837
|
+
category: "done_signals", name: "savepoint_truthful", status: "warn",
|
|
838
|
+
message: "#{phantoms.size} intent#{phantoms.size == 1 ? "" : "s"} carry savepoint.md " \
|
|
839
|
+
"line(s) contradicted by disk (advisory; terminal history stays report-only)",
|
|
840
|
+
details: phantoms, fixable: true,
|
|
841
|
+
fix_hint: "For a live (Active) intent, run plastic-intent-savepoint to rebuild via " \
|
|
842
|
+
"Bridge.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
|
|
843
|
+
"a phantom there stays advisory unless an explicit human grant authorizes the " \
|
|
844
|
+
"124a manual Done-bookend repair."
|
|
845
|
+
)
|
|
767
846
|
end
|
|
768
847
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
772
|
-
# ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
|
|
773
|
-
# run) filters only the REPORTED findings by origin scope.
|
|
774
|
-
def links_projection_check(scopes: nil)
|
|
775
|
-
all_dirs = all_intent_dirs
|
|
848
|
+
checks
|
|
849
|
+
end
|
|
776
850
|
|
|
851
|
+
# Build the cross-store node maps (basename + label per store) + relocation map from ALL
|
|
852
|
+
# stores (intent 222): the one shared build both the store-wide links_projection_check and
|
|
853
|
+
# the new per-intent intent_links_projection_check_for call, so the two can never disagree
|
|
854
|
+
# on how a ref resolves.
|
|
855
|
+
def build_links_projection_context
|
|
856
|
+
all_dirs = all_intent_dirs
|
|
777
857
|
store_index = seeded_store_index
|
|
778
858
|
node_index = Hash.new { |h, k| h[k] = {} }
|
|
779
859
|
intents = [] # { scope:, id:, sources:, chain:, path: }
|
|
@@ -796,6 +876,20 @@ class Doctor
|
|
|
796
876
|
end
|
|
797
877
|
|
|
798
878
|
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
879
|
+
{ store_index: store_index, node_index: node_index, relocation_map: relocation_map, intents: intents }
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
# Build the cross-store node maps (basename + label per store) + relocation map
|
|
883
|
+
# from ALL stores, then for every intent compute its canonical `## Links`
|
|
884
|
+
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
885
|
+
# ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
|
|
886
|
+
# run) filters only the REPORTED findings by origin scope.
|
|
887
|
+
def links_projection_check(scopes: nil)
|
|
888
|
+
ctx = build_links_projection_context
|
|
889
|
+
store_index = ctx[:store_index]
|
|
890
|
+
node_index = ctx[:node_index]
|
|
891
|
+
relocation_map = ctx[:relocation_map]
|
|
892
|
+
intents = ctx[:intents]
|
|
799
893
|
|
|
800
894
|
findings = []
|
|
801
895
|
intents.each do |node|
|
|
@@ -849,6 +943,171 @@ class Doctor
|
|
|
849
943
|
LinksSection.extract_section(IntentValidator.body_of(File.read(path)))
|
|
850
944
|
end
|
|
851
945
|
|
|
946
|
+
# --- Check scope 4: per-intent structure gate at intent-end (intent 222) ---
|
|
947
|
+
#
|
|
948
|
+
# Doctor's fourth check scope, invoked by `--intent <id>`. Never a store-wide sweep:
|
|
949
|
+
# resolves exactly one intent directory (mirrors scripts/end-intent's resolve_intent_dir /
|
|
950
|
+
# scripts/project-links's --intent disambiguation) and returns five checks, four
|
|
951
|
+
# FAIL-severity, one WARN-severity (intent_savepoint_truthful stays WARN per intent 134's
|
|
952
|
+
# binding advisory-only ruling; see spec.md D2/D8 - do NOT escalate it to FAIL).
|
|
953
|
+
def check_intent_end(id, store: nil, disposition: nil)
|
|
954
|
+
intent_dir, scope = resolve_single_intent_dir(id, store: store)
|
|
955
|
+
unless intent_dir
|
|
956
|
+
return [check(
|
|
957
|
+
category: "intent_end", name: "intent_resolved", status: "fail",
|
|
958
|
+
message: "no intent directory matches #{id.inspect}" \
|
|
959
|
+
"#{store ? " under store #{store.inspect}" : ""}"
|
|
960
|
+
)]
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
[
|
|
964
|
+
intent_structure_check(intent_dir),
|
|
965
|
+
intent_lifecycle_artifacts_check(intent_dir, disposition),
|
|
966
|
+
intent_checklist_complete_check(intent_dir),
|
|
967
|
+
intent_links_projection_check_for(id, scope),
|
|
968
|
+
intent_savepoint_truthful_check(intent_dir),
|
|
969
|
+
]
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
# Resolves --intent (+ optional --store) to exactly one (intent_dir, scope) pair, or
|
|
973
|
+
# [nil, nil] when found nowhere. `store`, when given, must already be a resolved scope key
|
|
974
|
+
# (e.g. "global" or "project:<slug>", matching all_intent_dirs' own `d[:scope]`); the CLI
|
|
975
|
+
# boundary (`cli`) is what translates the loosely-typed --store flag value into this shape.
|
|
976
|
+
# Aborts loud (raises) on cross-store ambiguity when no scope was given, mirroring
|
|
977
|
+
# scripts/project-links's resolve_target_store.
|
|
978
|
+
def resolve_single_intent_dir(id, store: nil)
|
|
979
|
+
candidates = all_intent_dirs.select { |d| d[:name].start_with?("#{id}--") }
|
|
980
|
+
candidates = candidates.select { |d| d[:scope] == store } if store
|
|
981
|
+
|
|
982
|
+
return [nil, nil] if candidates.empty?
|
|
983
|
+
if candidates.length > 1
|
|
984
|
+
scopes = candidates.map { |d| d[:scope] }.join(", ")
|
|
985
|
+
raise "intent #{id.inspect} is ambiguous across stores (#{scopes}); pass --store <key> " \
|
|
986
|
+
"to disambiguate (e.g. --store #{candidates.first[:scope]})"
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
[candidates.first[:path], candidates.first[:scope]]
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
def intent_structure_check(intent_dir)
|
|
993
|
+
result = IntentValidator.validate(intent_dir)
|
|
994
|
+
if result[:ok]
|
|
995
|
+
check(category: "intent_end", name: "intent_structure", status: "pass",
|
|
996
|
+
message: "Intent file frontmatter and sections are well-formed")
|
|
997
|
+
else
|
|
998
|
+
check(category: "intent_end", name: "intent_structure", status: "fail",
|
|
999
|
+
message: "Intent file has #{result[:errors].size} structural error(s)",
|
|
1000
|
+
details: result[:errors])
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
INTENT_END_LIFECYCLE_FILES = %w[spec.md plan.md checklist.md outcome.md].freeze
|
|
1005
|
+
|
|
1006
|
+
def intent_lifecycle_artifacts_check(intent_dir, disposition)
|
|
1007
|
+
missing = INTENT_END_LIFECYCLE_FILES.select { |f| !Bridge.stage_file_present?(File.join(intent_dir, f)) }
|
|
1008
|
+
unless Bridge.stage_file_present?(Bridge.intent_file(intent_dir))
|
|
1009
|
+
missing = [File.basename(Bridge.intent_file(intent_dir))] + missing
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
outcome_note = nil
|
|
1013
|
+
if disposition && !missing.include?("outcome.md")
|
|
1014
|
+
outcome_note = OutcomeGuard.reason(intent_dir, disposition)
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
if missing.empty? && outcome_note.nil?
|
|
1018
|
+
check(category: "intent_end", name: "intent_lifecycle_artifacts", status: "pass",
|
|
1019
|
+
message: "Every lifecycle artifact is present and real")
|
|
1020
|
+
else
|
|
1021
|
+
details = missing.map { |f| "#{f} missing or still a placeholder" }
|
|
1022
|
+
details << outcome_note if outcome_note
|
|
1023
|
+
check(category: "intent_end", name: "intent_lifecycle_artifacts", status: "fail",
|
|
1024
|
+
message: "#{details.size} lifecycle-artifact gap(s)", details: details)
|
|
1025
|
+
end
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
def intent_checklist_complete_check(intent_dir)
|
|
1029
|
+
path = File.join(intent_dir, "checklist.md")
|
|
1030
|
+
unless Bridge.stage_file_present?(path)
|
|
1031
|
+
return check(category: "intent_end", name: "intent_checklist_complete", status: "pass",
|
|
1032
|
+
message: "n/a: checklist.md absent or placeholder (flagged above if that is a gap)")
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
unchecked = File.read(path).scan(/^- \[ \].*$/)
|
|
1036
|
+
if unchecked.empty?
|
|
1037
|
+
check(category: "intent_end", name: "intent_checklist_complete", status: "pass",
|
|
1038
|
+
message: "checklist.md has zero unchecked items")
|
|
1039
|
+
else
|
|
1040
|
+
check(category: "intent_end", name: "intent_checklist_complete", status: "fail",
|
|
1041
|
+
message: "#{unchecked.size} unchecked checklist item(s) remain", details: unchecked)
|
|
1042
|
+
end
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
def intent_links_projection_check_for(id, scope)
|
|
1046
|
+
ctx = build_links_projection_context
|
|
1047
|
+
node = ctx[:intents].find { |n| n[:id] == id && n[:scope] == scope }
|
|
1048
|
+
unless node
|
|
1049
|
+
return check(category: "intent_end", name: "intent_links_projection", status: "warn",
|
|
1050
|
+
message: "could not locate #{id.inspect} in the links-projection graph")
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
resolve = ->(ref) do
|
|
1054
|
+
LinksProjection.resolve_ref_projection(
|
|
1055
|
+
ref, referer_store: node[:scope], relocation_map: ctx[:relocation_map],
|
|
1056
|
+
store_index: ctx[:store_index], node_index: ctx[:node_index]
|
|
1057
|
+
)
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
begin
|
|
1061
|
+
expected = LinksProjection.section(sources: node[:sources], chain: node[:chain], resolve: resolve)
|
|
1062
|
+
actual = actual_links_section(node[:path])
|
|
1063
|
+
rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
|
|
1064
|
+
return check(category: "intent_end", name: "intent_links_projection", status: "fail",
|
|
1065
|
+
message: "## Links projection failed: #{e.message}")
|
|
1066
|
+
end
|
|
1067
|
+
|
|
1068
|
+
if actual == expected
|
|
1069
|
+
check(category: "intent_end", name: "intent_links_projection", status: "pass",
|
|
1070
|
+
message: "## Links matches its frontmatter projection")
|
|
1071
|
+
else
|
|
1072
|
+
check(category: "intent_end", name: "intent_links_projection", status: "fail",
|
|
1073
|
+
message: "## Links does not match its frontmatter projection (membership/ordering drift)",
|
|
1074
|
+
fixable: true,
|
|
1075
|
+
fix_hint: "Run scripts/project-links --intent #{id} --apply (via maintenance-run)")
|
|
1076
|
+
end
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
# WARN-only, per intent 134 (savepoint truthfulness is advisory, never a hard gate). Do not
|
|
1080
|
+
# change this to FAIL: it would silently contradict a standing, binding ruling.
|
|
1081
|
+
def intent_savepoint_truthful_check(intent_dir)
|
|
1082
|
+
savepoint = File.join(intent_dir, "savepoint.md")
|
|
1083
|
+
unless File.exist?(savepoint)
|
|
1084
|
+
return check(category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
|
|
1085
|
+
message: "savepoint.md is missing")
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
first_line = File.read(savepoint).each_line.find { |l| !l.strip.empty? }.to_s.strip
|
|
1089
|
+
parts = first_line.split(/\s{2,}/)
|
|
1090
|
+
born_pair = parts.length >= 3 ? [parts[1], parts[2]] : nil
|
|
1091
|
+
expected_pair = Bridge.savepoint_milestone(intent_dir, File.basename(Bridge.intent_file(intent_dir)))
|
|
1092
|
+
|
|
1093
|
+
phantoms = Bridge.savepoint_phantom_lines(intent_dir)
|
|
1094
|
+
problems = []
|
|
1095
|
+
problems << "born line #{born_pair.inspect} does not match the expected #{expected_pair.inspect}" \
|
|
1096
|
+
if born_pair != expected_pair
|
|
1097
|
+
problems << "#{phantoms.size} phantom savepoint line(s): " \
|
|
1098
|
+
"#{phantoms.map { |l, r| "#{l} (#{r})" }.join("; ")}" if phantoms.any?
|
|
1099
|
+
|
|
1100
|
+
if problems.empty?
|
|
1101
|
+
check(category: "intent_end", name: "intent_savepoint_truthful", status: "pass",
|
|
1102
|
+
message: "Savepoint born line and phantom-line state are truthful")
|
|
1103
|
+
else
|
|
1104
|
+
check(category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
|
|
1105
|
+
message: "#{problems.size} savepoint truthfulness issue(s) (advisory, never blocking)",
|
|
1106
|
+
details: problems,
|
|
1107
|
+
fixable: true, fix_hint: "Run plastic-intent-savepoint to rebuild via Bridge.rebuild_savepoint")
|
|
1108
|
+
end
|
|
1109
|
+
end
|
|
1110
|
+
|
|
852
1111
|
# Build the relocation map + cross-store store_index from ALL stores, then for
|
|
853
1112
|
# every intent's cross-store `sources`/`chain` ref resolve it and flag:
|
|
854
1113
|
# - DEAD: the target resolves nowhere
|
|
@@ -1632,7 +1891,7 @@ class Doctor
|
|
|
1632
1891
|
|
|
1633
1892
|
# --- Check category 4: Core files ---
|
|
1634
1893
|
|
|
1635
|
-
def check_core_files(agent_key)
|
|
1894
|
+
def check_core_files(agent_key, include_drift: true)
|
|
1636
1895
|
checks = []
|
|
1637
1896
|
|
|
1638
1897
|
# plastic_md
|
|
@@ -1746,7 +2005,7 @@ class Doctor
|
|
|
1746
2005
|
end
|
|
1747
2006
|
end
|
|
1748
2007
|
|
|
1749
|
-
checks += check_agent_model_drift(agent_key)
|
|
2008
|
+
checks += check_agent_model_drift(agent_key) if include_drift
|
|
1750
2009
|
|
|
1751
2010
|
checks
|
|
1752
2011
|
end
|
|
@@ -2074,6 +2333,58 @@ class Doctor
|
|
|
2074
2333
|
checks
|
|
2075
2334
|
end
|
|
2076
2335
|
|
|
2336
|
+
# --- Check category: registered project paths (core-only, D1) ---
|
|
2337
|
+
#
|
|
2338
|
+
# D1 requires every registered project's store to have its repository path resolve to a
|
|
2339
|
+
# real, existing directory. check_project_store (above) only validates the plastic-home-side
|
|
2340
|
+
# mirror (~/.plastic/projects/<slug>/{store,INDEX.md,project.yml}); this method validates the
|
|
2341
|
+
# registered repo path itself (projects.yml's "path" key), decoupled from 175's deeper
|
|
2342
|
+
# repo-root-versus-registered-path question (219 D9).
|
|
2343
|
+
def check_registered_project_paths
|
|
2344
|
+
checks = []
|
|
2345
|
+
|
|
2346
|
+
projects_yml_path = File.join(plastic_home, "projects.yml")
|
|
2347
|
+
projects_data = load_yaml_safe(projects_yml_path)
|
|
2348
|
+
|
|
2349
|
+
if projects_data.nil?
|
|
2350
|
+
checks << check(
|
|
2351
|
+
category: "project_stores", name: "registered_project_paths", status: "fail",
|
|
2352
|
+
message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
|
|
2353
|
+
fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
|
|
2354
|
+
)
|
|
2355
|
+
return checks
|
|
2356
|
+
end
|
|
2357
|
+
|
|
2358
|
+
projects = projects_data["projects"]
|
|
2359
|
+
unless projects.is_a?(Hash) && !projects.empty?
|
|
2360
|
+
checks << check(
|
|
2361
|
+
category: "project_stores", name: "registered_project_paths", status: "pass",
|
|
2362
|
+
message: "No projects registered"
|
|
2363
|
+
)
|
|
2364
|
+
return checks
|
|
2365
|
+
end
|
|
2366
|
+
|
|
2367
|
+
projects.each do |slug, project_info|
|
|
2368
|
+
path = project_info.is_a?(Hash) ? project_info["path"] : nil
|
|
2369
|
+
|
|
2370
|
+
if path && File.directory?(path)
|
|
2371
|
+
checks << check(
|
|
2372
|
+
category: "project_stores", name: "project_path_resolves", status: "pass",
|
|
2373
|
+
message: "Registered path for '#{slug}' resolves to a real directory"
|
|
2374
|
+
)
|
|
2375
|
+
else
|
|
2376
|
+
checks << check(
|
|
2377
|
+
category: "project_stores", name: "project_path_resolves", status: "fail",
|
|
2378
|
+
message: "Registered path for '#{slug}' does not resolve to a real directory",
|
|
2379
|
+
details: [path ? tilde(path) : "(no path set in projects.yml)"],
|
|
2380
|
+
fixable: false
|
|
2381
|
+
)
|
|
2382
|
+
end
|
|
2383
|
+
end
|
|
2384
|
+
|
|
2385
|
+
checks
|
|
2386
|
+
end
|
|
2387
|
+
|
|
2077
2388
|
# Per-project validation extracted from check_project_stores so a single
|
|
2078
2389
|
# project can be checked in isolation (used by `--store <slug>`).
|
|
2079
2390
|
def check_project_store(slug, project_info)
|
|
@@ -2386,7 +2697,7 @@ class Doctor
|
|
|
2386
2697
|
# When present, we report whether every Plastic store is registered as a QMD
|
|
2387
2698
|
# collection. Everything here is read-only; we never invoke a mutating qmd
|
|
2388
2699
|
# subcommand. detector/runner are injectable so tests stay hermetic.
|
|
2389
|
-
def check_qmd(detector: QmdSync.method(:detect), runner: QmdSync.default_runner)
|
|
2700
|
+
def check_qmd(detector: QmdSync.method(:detect), runner: QmdSync.default_runner, collection: nil)
|
|
2390
2701
|
return [absent_qmd_check] unless detector.call
|
|
2391
2702
|
|
|
2392
2703
|
checks = [check(
|
|
@@ -2395,17 +2706,20 @@ class Doctor
|
|
|
2395
2706
|
)]
|
|
2396
2707
|
|
|
2397
2708
|
status = QmdSync.status(plastic_home: plastic_home, runner: runner, detector: detector)
|
|
2398
|
-
|
|
2709
|
+
expected = collection ? [collection] : (status[:expected] || [])
|
|
2710
|
+
missing = collection ? (Array(status[:missing]) & expected) : (status[:missing] || [])
|
|
2399
2711
|
|
|
2400
|
-
if
|
|
2712
|
+
if missing.empty?
|
|
2401
2713
|
checks << check(
|
|
2402
2714
|
category: "qmd", name: "collections", status: "pass",
|
|
2403
|
-
message: "
|
|
2715
|
+
message: collection ? "Store collection '#{collection}' registered as a QMD collection" \
|
|
2716
|
+
: "All #{expected.size} Plastic store(s) registered as QMD collections"
|
|
2404
2717
|
)
|
|
2405
2718
|
else
|
|
2406
2719
|
checks << check(
|
|
2407
2720
|
category: "qmd", name: "collections", status: "warn",
|
|
2408
|
-
message: "#{
|
|
2721
|
+
message: collection ? "Store collection '#{collection}' not registered as a QMD collection" \
|
|
2722
|
+
: "#{missing.size} Plastic store(s) not registered as QMD collections",
|
|
2409
2723
|
details: missing,
|
|
2410
2724
|
fixable: true, fix_hint: "Run: qmd-sync register --all"
|
|
2411
2725
|
)
|
|
@@ -2421,6 +2735,47 @@ class Doctor
|
|
|
2421
2735
|
)
|
|
2422
2736
|
end
|
|
2423
2737
|
|
|
2738
|
+
# --- Check category: tool readiness (Serena, Enola) ---
|
|
2739
|
+
#
|
|
2740
|
+
# One readiness check per recognized code-navigation power tool (intent 221, D4). Wraps
|
|
2741
|
+
# PowerTools' existing pure, injectable presence detectors (scripts/lib/power_tools.rb).
|
|
2742
|
+
# Absence is a PASS with a note, never a warn, mirroring check_qmd's absent-is-a-silent-pass
|
|
2743
|
+
# shape (108 D8: power tools are recommendations, never a hard gate). No fail path exists for
|
|
2744
|
+
# either check: PowerTools' detectors are pure presence probes (a marker-directory walk or a
|
|
2745
|
+
# PATH scan) with no handshake to test whether a detected tool actually works, so there is
|
|
2746
|
+
# nothing to report beyond present/absent.
|
|
2747
|
+
def check_serena(cwd:, path_probe: PowerTools.method(:which_serena), marker_finder: PowerTools.method(:serena_marker?))
|
|
2748
|
+
present = PowerTools.serena?(cwd: cwd, path_probe: path_probe, marker_finder: marker_finder)
|
|
2749
|
+
|
|
2750
|
+
if present
|
|
2751
|
+
[check(
|
|
2752
|
+
category: "tools", name: "serena_ready", status: "pass",
|
|
2753
|
+
message: "Serena is available for code navigation"
|
|
2754
|
+
)]
|
|
2755
|
+
else
|
|
2756
|
+
[check(
|
|
2757
|
+
category: "tools", name: "serena_ready", status: "pass",
|
|
2758
|
+
message: "Serena not installed (optional code-navigation tool)"
|
|
2759
|
+
)]
|
|
2760
|
+
end
|
|
2761
|
+
end
|
|
2762
|
+
|
|
2763
|
+
def check_enola(cwd:, path_probe: PowerTools.method(:which_enola), marker_finder: PowerTools.method(:enola_marker?))
|
|
2764
|
+
present = PowerTools.enola?(cwd: cwd, path_probe: path_probe, marker_finder: marker_finder)
|
|
2765
|
+
|
|
2766
|
+
if present
|
|
2767
|
+
[check(
|
|
2768
|
+
category: "tools", name: "enola_ready", status: "pass",
|
|
2769
|
+
message: "Enola is available for code navigation"
|
|
2770
|
+
)]
|
|
2771
|
+
else
|
|
2772
|
+
[check(
|
|
2773
|
+
category: "tools", name: "enola_ready", status: "pass",
|
|
2774
|
+
message: "Enola not installed (optional code-navigation tool)"
|
|
2775
|
+
)]
|
|
2776
|
+
end
|
|
2777
|
+
end
|
|
2778
|
+
|
|
2424
2779
|
# --- Check category: skill-lint (advisory only; intent 85b) ---
|
|
2425
2780
|
#
|
|
2426
2781
|
# Reports SkillLint's five structural checks over the skills/ tree as a
|
|
@@ -2468,47 +2823,88 @@ class Doctor
|
|
|
2468
2823
|
def run_checks(agent_key)
|
|
2469
2824
|
all_checks = []
|
|
2470
2825
|
all_checks += check_global_store
|
|
2471
|
-
all_checks += check_conventions
|
|
2826
|
+
all_checks += check_conventions(scopes: ["global"])
|
|
2472
2827
|
all_checks += check_agent_registration(agent_key)
|
|
2473
2828
|
all_checks += check_core_files(agent_key)
|
|
2474
|
-
all_checks += check_project_stores
|
|
2475
2829
|
all_checks += check_deprecations
|
|
2476
2830
|
all_checks += check_config_asks(agent_key)
|
|
2477
2831
|
all_checks += check_qmd
|
|
2478
|
-
all_checks += check_done_signals
|
|
2832
|
+
all_checks += check_done_signals(scopes: ["global"])
|
|
2479
2833
|
all_checks += check_skill_lint
|
|
2480
2834
|
all_checks += check_install_integrity
|
|
2481
2835
|
|
|
2482
2836
|
summarize(all_checks, agent_key)
|
|
2483
2837
|
end
|
|
2484
2838
|
|
|
2485
|
-
# Binary core sync check: agent registration + core files + manifest
|
|
2486
|
-
#
|
|
2487
|
-
# "fail" (and "warn" is never
|
|
2839
|
+
# Binary core sync check: agent registration + core files (drift excluded) + manifest
|
|
2840
|
+
# sync + registered project paths + global store availability, rolled up with binary:
|
|
2841
|
+
# true so ANY warn or fail makes the overall status "fail" (and "warn" is never
|
|
2842
|
+
# emitted). Used by `doctor.rb --core` (219 D1/D2: operational readiness only, no
|
|
2843
|
+
# agent_model_drift, no store-content scanning).
|
|
2488
2844
|
def run_core_checks(agent_key)
|
|
2489
2845
|
all_checks = []
|
|
2490
2846
|
all_checks += check_agent_registration(agent_key)
|
|
2491
|
-
all_checks += check_core_files(agent_key)
|
|
2847
|
+
all_checks += check_core_files(agent_key, include_drift: false)
|
|
2492
2848
|
all_checks += check_manifest_sync(agent_key)
|
|
2849
|
+
all_checks += check_registered_project_paths
|
|
2850
|
+
all_checks += check_global_store_available
|
|
2493
2851
|
|
|
2494
2852
|
summarize(all_checks, agent_key, binary: true)
|
|
2495
2853
|
end
|
|
2496
2854
|
|
|
2855
|
+
# Per-intent structure gate for `doctor.rb --intent <id> [--store <key>] [--disposition ...]`
|
|
2856
|
+
# (intent 222). `store`, here, is already the resolved scope key (see
|
|
2857
|
+
# normalize_intent_store_scope, the CLI-boundary translator that turns the loosely-typed
|
|
2858
|
+
# --store flag value into this shape before calling in).
|
|
2859
|
+
def run_intent_check(id, store: nil, disposition: nil)
|
|
2860
|
+
checks = check_intent_end(id, store: store, disposition: disposition)
|
|
2861
|
+
summarize(checks, "claude", binary: false)
|
|
2862
|
+
end
|
|
2863
|
+
|
|
2864
|
+
# Translate the loosely-typed --store flag value (nil / :all / :global / a bare project
|
|
2865
|
+
# slug String, see parse_args) into the scope-key shape all_intent_dirs' own `d[:scope]`
|
|
2866
|
+
# uses ("global" / "project:<slug>"), for --intent disambiguation only. --store with no
|
|
2867
|
+
# value (:all) means "no disambiguation given" here, distinct from its store-scan meaning.
|
|
2868
|
+
def normalize_intent_store_scope(store)
|
|
2869
|
+
case store
|
|
2870
|
+
when nil, :all then nil
|
|
2871
|
+
when :global then "global"
|
|
2872
|
+
else "project:#{store}"
|
|
2873
|
+
end
|
|
2874
|
+
end
|
|
2875
|
+
|
|
2497
2876
|
# Store-scoped checks for `doctor.rb --store [global|<slug>]`.
|
|
2498
2877
|
# :all -> global store + all project stores + all conventions
|
|
2499
2878
|
# :global -> global store + conventions scoped to ["global"]
|
|
2500
2879
|
# "<slug>" -> that project only + conventions scoped to ["project:<slug>"]
|
|
2501
2880
|
# (fail if the slug is not registered in projects.yml)
|
|
2502
2881
|
# 3-state roll-up (pass/warn/fail), like the full run.
|
|
2503
|
-
|
|
2882
|
+
# QMD reachability is now wired into every branch, scoped to that branch's own
|
|
2883
|
+
# collection(s) (D3); Serena/Enola readiness is per-slug only (D4).
|
|
2884
|
+
# Same injection seams as all_checks_for_project_slug (intent 221a), threaded through
|
|
2885
|
+
# so every branch's check_qmd/check_serena/check_enola call can be made hermetic.
|
|
2886
|
+
# Defaults are byte-identical to the real probes; both production callers
|
|
2887
|
+
# (scripts/doctor.rb's CLI entry point and scripts/dashboard.rb) call
|
|
2888
|
+
# run_store_checks(store) with a single positional argument and no kwargs, so
|
|
2889
|
+
# behavior at those call sites is unchanged.
|
|
2890
|
+
def run_store_checks(store, qmd_detector: QmdSync.method(:detect), qmd_runner: QmdSync.default_runner,
|
|
2891
|
+
serena_path_probe: PowerTools.method(:which_serena),
|
|
2892
|
+
serena_marker_finder: PowerTools.method(:serena_marker?),
|
|
2893
|
+
enola_path_probe: PowerTools.method(:which_enola),
|
|
2894
|
+
enola_marker_finder: PowerTools.method(:enola_marker?))
|
|
2504
2895
|
all_checks =
|
|
2505
2896
|
case store
|
|
2506
2897
|
when :all
|
|
2507
|
-
check_global_store + check_project_stores + check_conventions + check_done_signals
|
|
2898
|
+
check_global_store + check_project_stores + check_conventions + check_done_signals +
|
|
2899
|
+
check_qmd(detector: qmd_detector, runner: qmd_runner)
|
|
2508
2900
|
when :global
|
|
2509
|
-
check_global_store + check_conventions(scopes: ["global"]) +
|
|
2901
|
+
check_global_store + check_conventions(scopes: ["global"]) +
|
|
2902
|
+
check_done_signals(scopes: ["global"]) +
|
|
2903
|
+
check_qmd(detector: qmd_detector, runner: qmd_runner, collection: "plastic-global")
|
|
2510
2904
|
else
|
|
2511
|
-
all_checks_for_project_slug(store
|
|
2905
|
+
all_checks_for_project_slug(store, qmd_detector: qmd_detector, qmd_runner: qmd_runner,
|
|
2906
|
+
serena_path_probe: serena_path_probe, serena_marker_finder: serena_marker_finder,
|
|
2907
|
+
enola_path_probe: enola_path_probe, enola_marker_finder: enola_marker_finder)
|
|
2512
2908
|
end
|
|
2513
2909
|
|
|
2514
2910
|
summarize(all_checks, "claude", binary: false)
|
|
@@ -2516,7 +2912,16 @@ class Doctor
|
|
|
2516
2912
|
|
|
2517
2913
|
# Build the checks for a single project slug, or a lone fail check when the
|
|
2518
2914
|
# slug is unknown.
|
|
2519
|
-
|
|
2915
|
+
# qmd_detector/qmd_runner and the serena_/enola_ path_probe/marker_finder kwargs are
|
|
2916
|
+
# injection seams (intent 221a) mirroring check_qmd/check_serena/check_enola's own
|
|
2917
|
+
# kwargs, defaulted to the same real probes those methods already default to. No
|
|
2918
|
+
# caller passes these; they exist so tests can make host state (QMD/Serena/Enola
|
|
2919
|
+
# presence) irrelevant.
|
|
2920
|
+
def all_checks_for_project_slug(slug, qmd_detector: QmdSync.method(:detect), qmd_runner: QmdSync.default_runner,
|
|
2921
|
+
serena_path_probe: PowerTools.method(:which_serena),
|
|
2922
|
+
serena_marker_finder: PowerTools.method(:serena_marker?),
|
|
2923
|
+
enola_path_probe: PowerTools.method(:which_enola),
|
|
2924
|
+
enola_marker_finder: PowerTools.method(:enola_marker?))
|
|
2520
2925
|
projects_data = load_yaml_safe(File.join(plastic_home, "projects.yml"))
|
|
2521
2926
|
projects = projects_data.is_a?(Hash) ? projects_data["projects"] : nil
|
|
2522
2927
|
|
|
@@ -2528,9 +2933,16 @@ class Doctor
|
|
|
2528
2933
|
)]
|
|
2529
2934
|
end
|
|
2530
2935
|
|
|
2531
|
-
|
|
2936
|
+
project_info = projects[slug]
|
|
2937
|
+
project_path = project_info.is_a?(Hash) ? project_info["path"] : nil
|
|
2938
|
+
probe_cwd = project_path || Dir.pwd
|
|
2939
|
+
|
|
2940
|
+
check_project_store(slug, project_info) +
|
|
2532
2941
|
check_conventions(scopes: ["project:#{slug}"]) +
|
|
2533
|
-
check_done_signals(scopes: ["project:#{slug}"])
|
|
2942
|
+
check_done_signals(scopes: ["project:#{slug}"]) +
|
|
2943
|
+
check_qmd(detector: qmd_detector, runner: qmd_runner, collection: "plastic-#{slug}") +
|
|
2944
|
+
check_serena(cwd: probe_cwd, path_probe: serena_path_probe, marker_finder: serena_marker_finder) +
|
|
2945
|
+
check_enola(cwd: probe_cwd, path_probe: enola_path_probe, marker_finder: enola_marker_finder)
|
|
2534
2946
|
end
|
|
2535
2947
|
|
|
2536
2948
|
# Roll a list of checks up into the standard result envelope.
|
|
@@ -2572,7 +2984,15 @@ class Doctor
|
|
|
2572
2984
|
end
|
|
2573
2985
|
|
|
2574
2986
|
result =
|
|
2575
|
-
if
|
|
2987
|
+
if flags[:intent]
|
|
2988
|
+
begin
|
|
2989
|
+
run_intent_check(flags[:intent], store: normalize_intent_store_scope(flags[:store]),
|
|
2990
|
+
disposition: flags[:disposition])
|
|
2991
|
+
rescue StandardError => e
|
|
2992
|
+
$stderr.puts "doctor: #{e.message}"
|
|
2993
|
+
exit 2
|
|
2994
|
+
end
|
|
2995
|
+
elsif !flags[:store].nil?
|
|
2576
2996
|
run_store_checks(flags[:store])
|
|
2577
2997
|
elsif flags[:core]
|
|
2578
2998
|
run_core_checks(flags[:agent])
|