@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/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/legacy_bookend_amnesty"
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
- bookend_amnesty: LEGACY_BOOKEND_AMNESTY, runner: Doctor.default_runner)
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
- def check_done_signals(scopes: nil)
612
- conflicts = [] # canonical INDEX-wins disagreement (fail)
613
- gaps = [] # terminal completeness gaps on immutable/legacy history (warn)
614
- stalled = [] # terminal but End tail unfinished (warn)
615
- phantoms = [] # savepoint.md line(s) disk evidence contradicts (warn, never fail; intent 134)
616
-
617
- done_signal_stores(scopes).each do |store|
618
- index_sections_by_dir(store[:index]).each do |dirname, in_sections|
619
- dir = File.join(store[:store_dir], dirname)
620
- next unless File.directory?(dir)
621
-
622
- terminal = (in_sections & ["Completed", "Abandoned"]).any?
623
- active = in_sections.include?("Active") && !terminal
624
- outcome = File.join(dir, "outcome.md")
625
- outcome_real = Bridge.stage_file_present?(outcome)
626
- label = "#{store[:scope]} store/#{dirname}"
627
-
628
- # HARD conflict: the deliverable exists but INDEX still says Active. This
629
- # is the one true INDEX-wins disagreement, so it stays a fail.
630
- if active && outcome_real
631
- conflicts << "#{label}: outcome.md is real but the intent is still under ## Active " \
632
- "(INDEX is canonical — move it to its terminal section or revert outcome.md)"
633
- end
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
- # Savepoint truthfulness (advisory, never fail; intent 134): a line the disk contradicts
636
- # (a phantom milestone, a duplicate pair, or a state line with an absent prerequisite).
637
- # Live intents auto-rebuild via plastic-intent-savepoint; terminal intents are immutable,
638
- # so a phantom there is report-only. Composition with intent 170a: a pre-161 legacy intent
639
- # on the frozen bookend amnesty is grandfathered here too. Its immutable history carries
640
- # known pre-convention drift (the same class the audit-echo gap forgives), so it must not
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
- # Completeness gap (warn, not fail): a terminal intent whose outcome.md is
654
- # missing/placeholder. Legacy terminal intents predate this convention and
655
- # are immutable, so this is advisory, never breakage.
656
- if terminal && !outcome_real
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
- next unless terminal
663
-
664
- # Audit echo (weakest signal, D1): the savepoint should carry a
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
- # Stalled completion: the End tail never released the delivery lock, so the
680
- # post-done window `[INDEX terminal -> Lock.release]` never closed.
681
- if File.exist?(Lock.path(dir))
682
- note = Lock.fresh?(dir) ? "delivery.lock still present (post-done window not closed)"
683
- : "delivery.lock is present and STALE"
684
- stalled << "#{label}: #{note} — the End tail did not finish"
685
- end
686
- end
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
- checks = []
755
+ checks = []
690
756
 
691
- # signals_agree: only the canonical INDEX-wins conflict is a hard fail, so
692
- # `doctor` never goes red on immutable legacy terminal intents.
693
- if conflicts.empty?
694
- checks << check(
695
- category: "done_signals", name: "signals_agree", status: "pass",
696
- message: "No done-signal conflicts (no intent has a real outcome.md while still Active)"
697
- )
698
- else
699
- checks << check(
700
- category: "done_signals", name: "signals_agree", status: "fail",
701
- message: "#{conflicts.size} done-signal conflict#{conflicts.size == 1 ? "" : "s"} " \
702
- "(INDEX ## Completed/## Abandoned is canonical; a real outcome.md must not stay Active)",
703
- details: conflicts, fixable: true,
704
- fix_hint: "Reconcile to INDEX (canonical): move the intent to its terminal section, or revert " \
705
- "outcome.md to a placeholder. Deliverable-exists but still-Active is the one done-signal " \
706
- "state that must never persist."
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
- # signals_complete: terminal completeness gaps are advisory (warn), because
711
- # completed intents are immutable and legacy history predates this convention.
712
- if gaps.empty?
713
- checks << check(
714
- category: "done_signals", name: "signals_complete", status: "pass",
715
- message: "Every terminal intent carries a real outcome.md and a Done savepoint echo"
716
- )
717
- else
718
- checks << check(
719
- category: "done_signals", name: "signals_complete", status: "warn",
720
- message: "#{gaps.size} terminal completeness gap#{gaps.size == 1 ? "" : "s"} " \
721
- "(outcome.md or the savepoint Done echo is missing; advisory on immutable history)",
722
- details: gaps, fixable: true,
723
- fix_hint: "For live terminals, author a real outcome.md with the `disposition:` header via the " \
724
- "completion/abandon path (plastic-auto or plastic-store-curating) and stamp the terminal " \
725
- "savepoint. Legacy terminal intents are immutable, so pre-convention gaps stay advisory."
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
- if stalled.empty?
730
- checks << check(
731
- category: "done_signals", name: "stalled_completion", status: "pass",
732
- message: "No stalled completions (every terminal intent released its delivery lock)"
733
- )
734
- else
735
- checks << check(
736
- category: "done_signals", name: "stalled_completion", status: "warn",
737
- message: "#{stalled.size} stalled completion#{stalled.size == 1 ? "" : "s"} " \
738
- "(terminal in INDEX but the End tail did not finish)",
739
- details: stalled, fixable: true,
740
- fix_hint: "Finish the End tail via stale-lock reclaim: run /plastic-doctor reclaim the lock, " \
741
- "then complete the tail (Worktree.release -> Lock.release -> purge -> QMD reindex " \
742
- "last). This FINISHES a completion; it is NOT a reactivation of a done intent."
743
- )
744
- end
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
- # savepoint_truthful: advisory only, never fails the run (intent 134). A phantom line on a
747
- # live intent auto-rebuilds; on terminal (immutable) history it stays report-only.
748
- if phantoms.empty?
749
- checks << check(
750
- category: "done_signals", name: "savepoint_truthful", status: "pass",
751
- message: "No savepoint.md lines are contradicted by disk (phantom-line check clean)"
752
- )
753
- else
754
- checks << check(
755
- category: "done_signals", name: "savepoint_truthful", status: "warn",
756
- message: "#{phantoms.size} intent#{phantoms.size == 1 ? "" : "s"} carry savepoint.md " \
757
- "line(s) contradicted by disk (advisory; terminal history stays report-only)",
758
- details: phantoms, fixable: true,
759
- fix_hint: "For a live (Active) intent, run plastic-intent-savepoint to rebuild via " \
760
- "Bridge.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
761
- "a phantom there stays advisory unless an explicit human grant authorizes the " \
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
- checks
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
- # Build the cross-store node maps (basename + label per store) + relocation map
770
- # from ALL stores, then for every intent compute its canonical `## Links`
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
- missing = status[:missing] || []
2709
+ expected = collection ? [collection] : (status[:expected] || [])
2710
+ missing = collection ? (Array(status[:missing]) & expected) : (status[:missing] || [])
2399
2711
 
2400
- if status[:all_registered]
2712
+ if missing.empty?
2401
2713
  checks << check(
2402
2714
  category: "qmd", name: "collections", status: "pass",
2403
- message: "All #{status[:expected].size} Plastic store(s) registered as QMD collections"
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: "#{missing.size} Plastic store(s) not registered as QMD collections",
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 sync,
2486
- # rolled up with binary: true so ANY warn or fail makes the overall status
2487
- # "fail" (and "warn" is never emitted). Used by `doctor.rb --core`.
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
- def run_store_checks(store)
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"]) + check_done_signals(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
- def all_checks_for_project_slug(slug)
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
- check_project_store(slug, projects[slug]) +
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 !flags[:store].nil?
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])