@zalom/plastic 1.7.1 → 1.9.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.
Files changed (72) hide show
  1. package/PLASTIC.md +75 -535
  2. package/README.md +3 -2
  3. package/agents/plastic-intent-curator.md +2 -2
  4. package/deprecations.yml +10 -2
  5. package/hooks/{links-gate → edit-gates} +1 -1
  6. package/hooks/hooks.json +4 -44
  7. package/hooks/power-tools +8 -0
  8. package/package.json +1 -2
  9. package/scripts/codex-hook +17 -22
  10. package/scripts/doctor.rb +688 -186
  11. package/scripts/end-intent +65 -22
  12. package/scripts/hook-code-gate +13 -28
  13. package/scripts/hook-create-gate +10 -57
  14. package/scripts/hook-edit-gates +58 -0
  15. package/scripts/hook-links-gate +8 -31
  16. package/scripts/hook-lock-gate +13 -58
  17. package/scripts/hook-power-tools +38 -0
  18. package/scripts/hook-savepoint-pre +8 -11
  19. package/scripts/lib/bridge.rb +2 -2
  20. package/scripts/lib/edit_gates.rb +342 -0
  21. package/scripts/lib/hook_registry.rb +61 -29
  22. package/scripts/lib/installer_core.rb +100 -18
  23. package/scripts/lib/outcome_guard.rb +38 -0
  24. package/scripts/lib/qmd_hook.rb +21 -41
  25. package/scripts/lib/qmd_sync.rb +0 -15
  26. package/scripts/lib/revisions_writer.rb +1 -1
  27. package/scripts/maintenance-run +56 -2
  28. package/scripts/restore-intent-v1 +16 -11
  29. package/skills/auto/SKILL.md +15 -4
  30. package/skills/auto/evals/evals.json +2 -2
  31. package/skills/conventions/SKILL.md +31 -0
  32. package/skills/conventions/references/completion-and-done.md +40 -0
  33. package/skills/conventions/references/gates-and-enforcement.md +23 -0
  34. package/skills/conventions/references/knowledge-graph.md +47 -0
  35. package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
  36. package/skills/conventions/references/locks-and-worktrees.md +113 -0
  37. package/skills/conventions/references/maintenance-and-revisions.md +170 -0
  38. package/skills/conventions/references/roadmaps.md +44 -0
  39. package/skills/conventions/references/tiers-and-dispatch.md +134 -0
  40. package/skills/doctor/SKILL.md +59 -19
  41. package/skills/intent-brainstorming/SKILL.md +4 -0
  42. package/skills/intent-continuing/SKILL.md +4 -0
  43. package/skills/intent-creating/SKILL.md +7 -2
  44. package/skills/intent-ending/SKILL.md +20 -6
  45. package/skills/intent-executing/SKILL.md +10 -0
  46. package/skills/intent-linking/SKILL.md +4 -0
  47. package/skills/intent-locking/SKILL.md +4 -0
  48. package/skills/intent-planning/SKILL.md +7 -0
  49. package/skills/intent-savepoint/SKILL.md +4 -0
  50. package/skills/intent-speccing/SKILL.md +4 -0
  51. package/skills/intent-starting/SKILL.md +10 -0
  52. package/skills/project-creating/references/project-scaffolding.md +2 -2
  53. package/skills/roadmap/SKILL.md +4 -0
  54. package/skills/roadmap-continuing/SKILL.md +4 -0
  55. package/skills/skill-creating/SKILL.md +3 -0
  56. package/skills/skill-creating/references/defaults-first.md +23 -0
  57. package/skills/skill-creating/references/hooks.md +4 -1
  58. package/skills/store-curating/SKILL.md +8 -0
  59. package/skills/store-indexing/SKILL.md +8 -0
  60. package/skills/tutorial/references/track-2-auto.md +2 -3
  61. package/templates/agents.md +8 -0
  62. package/PLASTIC-reference.md +0 -138
  63. package/hooks/code-gate +0 -27
  64. package/hooks/create-gate +0 -3
  65. package/hooks/lock-gate +0 -21
  66. package/hooks/qmd-search +0 -8
  67. package/hooks/retrieval-gate +0 -10
  68. package/hooks/savepoint-pre +0 -10
  69. package/scripts/hook-qmd-search +0 -44
  70. package/scripts/hook-retrieval-gate +0 -148
  71. package/scripts/lib/legacy_bookend_amnesty.rb +0 -35
  72. package/scripts/lib/retrieval_gate.rb +0 -211
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
@@ -513,7 +560,7 @@ class Doctor
513
560
  fixable: true,
514
561
  fix_hint: "Dispatch plastic-store-curating to relocate each unsanctioned section into the " \
515
562
  "intent's revisions.md via move-and-record (a missing required section is restored " \
516
- "or reprojected instead); see PLASTIC.md > Structural maintenance and revisions.md"
563
+ "or reprojected instead); see plastic-conventions > references/maintenance-and-revisions.md"
517
564
  )
518
565
  end
519
566
 
@@ -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|
@@ -835,7 +929,7 @@ class Doctor
835
929
  "project-links --intent <id> --apply` over running project-links directly: it detects " \
836
930
  "(never acquires) the target's delivery lock, requires a clean store working tree, and " \
837
931
  "commits the scoped change plus its revisions.md receipt as one merged operation " \
838
- "(PLASTIC.md > WORK vs MAINTENANCE)."
932
+ "(plastic-conventions > references/maintenance-and-revisions.md)."
839
933
  )
840
934
  end
841
935
 
@@ -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
@@ -970,7 +1229,7 @@ class Doctor
970
1229
  "Every sources/chain id resolves to a real intent (I4)",
971
1230
  "Dispatch plastic-store-curating to record the dangling sources/chain edge as a " \
972
1231
  "broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
973
- "PLASTIC.md > Structural maintenance and revisions.md), or restore the missing intent"
1232
+ "plastic-conventions > references/maintenance-and-revisions.md), or restore the missing intent"
974
1233
  )
975
1234
  checks
976
1235
  end
@@ -1088,6 +1347,12 @@ class Doctor
1088
1347
  )
1089
1348
  end
1090
1349
 
1350
+ # claude_hooks_implemented (intent 244): the edit-path gates now live as
1351
+ # branches inside one dispatcher (scripts/hook-edit-gates), which is
1352
+ # exactly the shape that let links-gate ship registered and dead on
1353
+ # Codex (intent 200's failure class, one level up).
1354
+ checks << claude_hooks_implemented_check
1355
+
1091
1356
  # hooks_registered — settings.json has Plastic hooks for required events
1092
1357
  settings_path = File.join(agent_dir, "settings.json")
1093
1358
  settings = read_json_safe(settings_path)
@@ -1178,6 +1443,82 @@ class Doctor
1178
1443
  checks
1179
1444
  end
1180
1445
 
1446
+ # claude_hooks_implemented (intent 244, the Claude twin of intent 200's Codex
1447
+ # check): the edit-path gates now live as branches inside one dispatcher,
1448
+ # which is exactly the shape that let links-gate ship registered and dead on
1449
+ # Codex. HookRegistry::GATE_TOOLS is the registry side; scripts/hook-edit-gates
1450
+ # is the implementation side. Checked in BOTH directions, so a registered gate
1451
+ # with no branch (always allows, silently) and a branch nobody registers
1452
+ # (dead code) are both reported.
1453
+ #
1454
+ # Read as plain text, never required: the dispatcher has top-level side effects
1455
+ # (it reads $stdin and exits), the same reason codex_dispatcher_gate_names reads
1456
+ # scripts/codex-hook as text.
1457
+ def claude_dispatcher_gate_names(source)
1458
+ case_start = source.index(/^\s*case gate\b/)
1459
+ return nil unless case_start
1460
+
1461
+ case_body = source[case_start..-1]
1462
+ end_idx = case_body.index(/^\s*end\b/)
1463
+ scanned = end_idx ? case_body[0...end_idx] : case_body
1464
+ names = scanned.scan(/^\s*when\s+"([^"]+)"/).flatten.uniq
1465
+ names.empty? ? nil : names
1466
+ end
1467
+
1468
+ def claude_hooks_implemented_check
1469
+ dispatcher_path = File.join(plastic_home, "scripts", "hook-edit-gates")
1470
+
1471
+ unless File.exist?(dispatcher_path)
1472
+ return check(
1473
+ category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
1474
+ message: "scripts/hook-edit-gates not found at #{tilde(dispatcher_path)}; cannot verify " \
1475
+ "the edit-path gate registry and dispatcher agree",
1476
+ fixable: true, fix_hint: "Re-run the Plastic installer"
1477
+ )
1478
+ end
1479
+
1480
+ dispatcher_names = claude_dispatcher_gate_names(File.read(dispatcher_path))
1481
+
1482
+ if dispatcher_names.nil?
1483
+ return check(
1484
+ category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
1485
+ message: "Could not read any gate names out of #{tilde(dispatcher_path)}: the `case gate` " \
1486
+ "statement no longer matches the shape this check expects, so the registry was " \
1487
+ "never actually checked against the real dispatcher. Update " \
1488
+ "claude_dispatcher_gate_names in doctor.rb to the file's new shape.",
1489
+ fixable: false
1490
+ )
1491
+ end
1492
+
1493
+ registry_names = HookRegistry::GATE_TOOLS.keys
1494
+ missing_branch = registry_names - dispatcher_names
1495
+ dead_branch = dispatcher_names - registry_names
1496
+
1497
+ if missing_branch.empty? && dead_branch.empty?
1498
+ return check(
1499
+ category: "agent_registration", name: "claude_hooks_implemented", status: "pass",
1500
+ message: "Every registered edit-path gate has a scripts/hook-edit-gates branch, and every " \
1501
+ "dispatcher branch is registered"
1502
+ )
1503
+ end
1504
+
1505
+ details = missing_branch.map do |name|
1506
+ "#{name} is in HookRegistry::GATE_TOOLS but scripts/hook-edit-gates has no branch for it, " \
1507
+ "so the dispatcher skips it on every edit and it never blocks anything"
1508
+ end
1509
+ details += dead_branch.map do |name|
1510
+ "#{name} has a branch in scripts/hook-edit-gates but is not in HookRegistry::GATE_TOOLS, so " \
1511
+ "the dispatcher never reaches it and it is dead code"
1512
+ end
1513
+
1514
+ check(
1515
+ category: "agent_registration", name: "claude_hooks_implemented", status: "fail",
1516
+ message: "The edit-path gate registry and scripts/hook-edit-gates disagree on " \
1517
+ "#{details.size} gate(s)",
1518
+ details: details, fixable: false
1519
+ )
1520
+ end
1521
+
1181
1522
  # Plastic skills install as ~/.claude/skills/plastic-<name>/SKILL.md. Pass if at
1182
1523
  # least one such skill is present.
1183
1524
  def flat_skills_check(agent_dir, installer_flag)
@@ -1632,7 +1973,7 @@ class Doctor
1632
1973
 
1633
1974
  # --- Check category 4: Core files ---
1634
1975
 
1635
- def check_core_files(agent_key)
1976
+ def check_core_files(agent_key, include_drift: true)
1636
1977
  checks = []
1637
1978
 
1638
1979
  # plastic_md
@@ -1746,7 +2087,7 @@ class Doctor
1746
2087
  end
1747
2088
  end
1748
2089
 
1749
- checks += check_agent_model_drift(agent_key)
2090
+ checks += check_agent_model_drift(agent_key) if include_drift
1750
2091
 
1751
2092
  checks
1752
2093
  end
@@ -2074,6 +2415,58 @@ class Doctor
2074
2415
  checks
2075
2416
  end
2076
2417
 
2418
+ # --- Check category: registered project paths (core-only, D1) ---
2419
+ #
2420
+ # D1 requires every registered project's store to have its repository path resolve to a
2421
+ # real, existing directory. check_project_store (above) only validates the plastic-home-side
2422
+ # mirror (~/.plastic/projects/<slug>/{store,INDEX.md,project.yml}); this method validates the
2423
+ # registered repo path itself (projects.yml's "path" key), decoupled from 175's deeper
2424
+ # repo-root-versus-registered-path question (219 D9).
2425
+ def check_registered_project_paths
2426
+ checks = []
2427
+
2428
+ projects_yml_path = File.join(plastic_home, "projects.yml")
2429
+ projects_data = load_yaml_safe(projects_yml_path)
2430
+
2431
+ if projects_data.nil?
2432
+ checks << check(
2433
+ category: "project_stores", name: "registered_project_paths", status: "fail",
2434
+ message: "projects.yml not found or invalid at #{tilde(projects_yml_path)}",
2435
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore projects.yml"
2436
+ )
2437
+ return checks
2438
+ end
2439
+
2440
+ projects = projects_data["projects"]
2441
+ unless projects.is_a?(Hash) && !projects.empty?
2442
+ checks << check(
2443
+ category: "project_stores", name: "registered_project_paths", status: "pass",
2444
+ message: "No projects registered"
2445
+ )
2446
+ return checks
2447
+ end
2448
+
2449
+ projects.each do |slug, project_info|
2450
+ path = project_info.is_a?(Hash) ? project_info["path"] : nil
2451
+
2452
+ if path && File.directory?(path)
2453
+ checks << check(
2454
+ category: "project_stores", name: "project_path_resolves", status: "pass",
2455
+ message: "Registered path for '#{slug}' resolves to a real directory"
2456
+ )
2457
+ else
2458
+ checks << check(
2459
+ category: "project_stores", name: "project_path_resolves", status: "fail",
2460
+ message: "Registered path for '#{slug}' does not resolve to a real directory",
2461
+ details: [path ? tilde(path) : "(no path set in projects.yml)"],
2462
+ fixable: false
2463
+ )
2464
+ end
2465
+ end
2466
+
2467
+ checks
2468
+ end
2469
+
2077
2470
  # Per-project validation extracted from check_project_stores so a single
2078
2471
  # project can be checked in isolation (used by `--store <slug>`).
2079
2472
  def check_project_store(slug, project_info)
@@ -2386,7 +2779,7 @@ class Doctor
2386
2779
  # When present, we report whether every Plastic store is registered as a QMD
2387
2780
  # collection. Everything here is read-only; we never invoke a mutating qmd
2388
2781
  # subcommand. detector/runner are injectable so tests stay hermetic.
2389
- def check_qmd(detector: QmdSync.method(:detect), runner: QmdSync.default_runner)
2782
+ def check_qmd(detector: QmdSync.method(:detect), runner: QmdSync.default_runner, collection: nil)
2390
2783
  return [absent_qmd_check] unless detector.call
2391
2784
 
2392
2785
  checks = [check(
@@ -2395,17 +2788,20 @@ class Doctor
2395
2788
  )]
2396
2789
 
2397
2790
  status = QmdSync.status(plastic_home: plastic_home, runner: runner, detector: detector)
2398
- missing = status[:missing] || []
2791
+ expected = collection ? [collection] : (status[:expected] || [])
2792
+ missing = collection ? (Array(status[:missing]) & expected) : (status[:missing] || [])
2399
2793
 
2400
- if status[:all_registered]
2794
+ if missing.empty?
2401
2795
  checks << check(
2402
2796
  category: "qmd", name: "collections", status: "pass",
2403
- message: "All #{status[:expected].size} Plastic store(s) registered as QMD collections"
2797
+ message: collection ? "Store collection '#{collection}' registered as a QMD collection" \
2798
+ : "All #{expected.size} Plastic store(s) registered as QMD collections"
2404
2799
  )
2405
2800
  else
2406
2801
  checks << check(
2407
2802
  category: "qmd", name: "collections", status: "warn",
2408
- message: "#{missing.size} Plastic store(s) not registered as QMD collections",
2803
+ message: collection ? "Store collection '#{collection}' not registered as a QMD collection" \
2804
+ : "#{missing.size} Plastic store(s) not registered as QMD collections",
2409
2805
  details: missing,
2410
2806
  fixable: true, fix_hint: "Run: qmd-sync register --all"
2411
2807
  )
@@ -2421,6 +2817,47 @@ class Doctor
2421
2817
  )
2422
2818
  end
2423
2819
 
2820
+ # --- Check category: tool readiness (Serena, Enola) ---
2821
+ #
2822
+ # One readiness check per recognized code-navigation power tool (intent 221, D4). Wraps
2823
+ # PowerTools' existing pure, injectable presence detectors (scripts/lib/power_tools.rb).
2824
+ # Absence is a PASS with a note, never a warn, mirroring check_qmd's absent-is-a-silent-pass
2825
+ # shape (108 D8: power tools are recommendations, never a hard gate). No fail path exists for
2826
+ # either check: PowerTools' detectors are pure presence probes (a marker-directory walk or a
2827
+ # PATH scan) with no handshake to test whether a detected tool actually works, so there is
2828
+ # nothing to report beyond present/absent.
2829
+ def check_serena(cwd:, path_probe: PowerTools.method(:which_serena), marker_finder: PowerTools.method(:serena_marker?))
2830
+ present = PowerTools.serena?(cwd: cwd, path_probe: path_probe, marker_finder: marker_finder)
2831
+
2832
+ if present
2833
+ [check(
2834
+ category: "tools", name: "serena_ready", status: "pass",
2835
+ message: "Serena is available for code navigation"
2836
+ )]
2837
+ else
2838
+ [check(
2839
+ category: "tools", name: "serena_ready", status: "pass",
2840
+ message: "Serena not installed (optional code-navigation tool)"
2841
+ )]
2842
+ end
2843
+ end
2844
+
2845
+ def check_enola(cwd:, path_probe: PowerTools.method(:which_enola), marker_finder: PowerTools.method(:enola_marker?))
2846
+ present = PowerTools.enola?(cwd: cwd, path_probe: path_probe, marker_finder: marker_finder)
2847
+
2848
+ if present
2849
+ [check(
2850
+ category: "tools", name: "enola_ready", status: "pass",
2851
+ message: "Enola is available for code navigation"
2852
+ )]
2853
+ else
2854
+ [check(
2855
+ category: "tools", name: "enola_ready", status: "pass",
2856
+ message: "Enola not installed (optional code-navigation tool)"
2857
+ )]
2858
+ end
2859
+ end
2860
+
2424
2861
  # --- Check category: skill-lint (advisory only; intent 85b) ---
2425
2862
  #
2426
2863
  # Reports SkillLint's five structural checks over the skills/ tree as a
@@ -2468,47 +2905,88 @@ class Doctor
2468
2905
  def run_checks(agent_key)
2469
2906
  all_checks = []
2470
2907
  all_checks += check_global_store
2471
- all_checks += check_conventions
2908
+ all_checks += check_conventions(scopes: ["global"])
2472
2909
  all_checks += check_agent_registration(agent_key)
2473
2910
  all_checks += check_core_files(agent_key)
2474
- all_checks += check_project_stores
2475
2911
  all_checks += check_deprecations
2476
2912
  all_checks += check_config_asks(agent_key)
2477
2913
  all_checks += check_qmd
2478
- all_checks += check_done_signals
2914
+ all_checks += check_done_signals(scopes: ["global"])
2479
2915
  all_checks += check_skill_lint
2480
2916
  all_checks += check_install_integrity
2481
2917
 
2482
2918
  summarize(all_checks, agent_key)
2483
2919
  end
2484
2920
 
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`.
2921
+ # Binary core sync check: agent registration + core files (drift excluded) + manifest
2922
+ # sync + registered project paths + global store availability, rolled up with binary:
2923
+ # true so ANY warn or fail makes the overall status "fail" (and "warn" is never
2924
+ # emitted). Used by `doctor.rb --core` (219 D1/D2: operational readiness only, no
2925
+ # agent_model_drift, no store-content scanning).
2488
2926
  def run_core_checks(agent_key)
2489
2927
  all_checks = []
2490
2928
  all_checks += check_agent_registration(agent_key)
2491
- all_checks += check_core_files(agent_key)
2929
+ all_checks += check_core_files(agent_key, include_drift: false)
2492
2930
  all_checks += check_manifest_sync(agent_key)
2931
+ all_checks += check_registered_project_paths
2932
+ all_checks += check_global_store_available
2493
2933
 
2494
2934
  summarize(all_checks, agent_key, binary: true)
2495
2935
  end
2496
2936
 
2937
+ # Per-intent structure gate for `doctor.rb --intent <id> [--store <key>] [--disposition ...]`
2938
+ # (intent 222). `store`, here, is already the resolved scope key (see
2939
+ # normalize_intent_store_scope, the CLI-boundary translator that turns the loosely-typed
2940
+ # --store flag value into this shape before calling in).
2941
+ def run_intent_check(id, store: nil, disposition: nil)
2942
+ checks = check_intent_end(id, store: store, disposition: disposition)
2943
+ summarize(checks, "claude", binary: false)
2944
+ end
2945
+
2946
+ # Translate the loosely-typed --store flag value (nil / :all / :global / a bare project
2947
+ # slug String, see parse_args) into the scope-key shape all_intent_dirs' own `d[:scope]`
2948
+ # uses ("global" / "project:<slug>"), for --intent disambiguation only. --store with no
2949
+ # value (:all) means "no disambiguation given" here, distinct from its store-scan meaning.
2950
+ def normalize_intent_store_scope(store)
2951
+ case store
2952
+ when nil, :all then nil
2953
+ when :global then "global"
2954
+ else "project:#{store}"
2955
+ end
2956
+ end
2957
+
2497
2958
  # Store-scoped checks for `doctor.rb --store [global|<slug>]`.
2498
2959
  # :all -> global store + all project stores + all conventions
2499
2960
  # :global -> global store + conventions scoped to ["global"]
2500
2961
  # "<slug>" -> that project only + conventions scoped to ["project:<slug>"]
2501
2962
  # (fail if the slug is not registered in projects.yml)
2502
2963
  # 3-state roll-up (pass/warn/fail), like the full run.
2503
- def run_store_checks(store)
2964
+ # QMD reachability is now wired into every branch, scoped to that branch's own
2965
+ # collection(s) (D3); Serena/Enola readiness is per-slug only (D4).
2966
+ # Same injection seams as all_checks_for_project_slug (intent 221a), threaded through
2967
+ # so every branch's check_qmd/check_serena/check_enola call can be made hermetic.
2968
+ # Defaults are byte-identical to the real probes; both production callers
2969
+ # (scripts/doctor.rb's CLI entry point and scripts/dashboard.rb) call
2970
+ # run_store_checks(store) with a single positional argument and no kwargs, so
2971
+ # behavior at those call sites is unchanged.
2972
+ def run_store_checks(store, qmd_detector: QmdSync.method(:detect), qmd_runner: QmdSync.default_runner,
2973
+ serena_path_probe: PowerTools.method(:which_serena),
2974
+ serena_marker_finder: PowerTools.method(:serena_marker?),
2975
+ enola_path_probe: PowerTools.method(:which_enola),
2976
+ enola_marker_finder: PowerTools.method(:enola_marker?))
2504
2977
  all_checks =
2505
2978
  case store
2506
2979
  when :all
2507
- check_global_store + check_project_stores + check_conventions + check_done_signals
2980
+ check_global_store + check_project_stores + check_conventions + check_done_signals +
2981
+ check_qmd(detector: qmd_detector, runner: qmd_runner)
2508
2982
  when :global
2509
- check_global_store + check_conventions(scopes: ["global"]) + check_done_signals(scopes: ["global"])
2983
+ check_global_store + check_conventions(scopes: ["global"]) +
2984
+ check_done_signals(scopes: ["global"]) +
2985
+ check_qmd(detector: qmd_detector, runner: qmd_runner, collection: "plastic-global")
2510
2986
  else
2511
- all_checks_for_project_slug(store)
2987
+ all_checks_for_project_slug(store, qmd_detector: qmd_detector, qmd_runner: qmd_runner,
2988
+ serena_path_probe: serena_path_probe, serena_marker_finder: serena_marker_finder,
2989
+ enola_path_probe: enola_path_probe, enola_marker_finder: enola_marker_finder)
2512
2990
  end
2513
2991
 
2514
2992
  summarize(all_checks, "claude", binary: false)
@@ -2516,7 +2994,16 @@ class Doctor
2516
2994
 
2517
2995
  # Build the checks for a single project slug, or a lone fail check when the
2518
2996
  # slug is unknown.
2519
- def all_checks_for_project_slug(slug)
2997
+ # qmd_detector/qmd_runner and the serena_/enola_ path_probe/marker_finder kwargs are
2998
+ # injection seams (intent 221a) mirroring check_qmd/check_serena/check_enola's own
2999
+ # kwargs, defaulted to the same real probes those methods already default to. No
3000
+ # caller passes these; they exist so tests can make host state (QMD/Serena/Enola
3001
+ # presence) irrelevant.
3002
+ def all_checks_for_project_slug(slug, qmd_detector: QmdSync.method(:detect), qmd_runner: QmdSync.default_runner,
3003
+ serena_path_probe: PowerTools.method(:which_serena),
3004
+ serena_marker_finder: PowerTools.method(:serena_marker?),
3005
+ enola_path_probe: PowerTools.method(:which_enola),
3006
+ enola_marker_finder: PowerTools.method(:enola_marker?))
2520
3007
  projects_data = load_yaml_safe(File.join(plastic_home, "projects.yml"))
2521
3008
  projects = projects_data.is_a?(Hash) ? projects_data["projects"] : nil
2522
3009
 
@@ -2528,9 +3015,16 @@ class Doctor
2528
3015
  )]
2529
3016
  end
2530
3017
 
2531
- check_project_store(slug, projects[slug]) +
3018
+ project_info = projects[slug]
3019
+ project_path = project_info.is_a?(Hash) ? project_info["path"] : nil
3020
+ probe_cwd = project_path || Dir.pwd
3021
+
3022
+ check_project_store(slug, project_info) +
2532
3023
  check_conventions(scopes: ["project:#{slug}"]) +
2533
- check_done_signals(scopes: ["project:#{slug}"])
3024
+ check_done_signals(scopes: ["project:#{slug}"]) +
3025
+ check_qmd(detector: qmd_detector, runner: qmd_runner, collection: "plastic-#{slug}") +
3026
+ check_serena(cwd: probe_cwd, path_probe: serena_path_probe, marker_finder: serena_marker_finder) +
3027
+ check_enola(cwd: probe_cwd, path_probe: enola_path_probe, marker_finder: enola_marker_finder)
2534
3028
  end
2535
3029
 
2536
3030
  # Roll a list of checks up into the standard result envelope.
@@ -2572,7 +3066,15 @@ class Doctor
2572
3066
  end
2573
3067
 
2574
3068
  result =
2575
- if !flags[:store].nil?
3069
+ if flags[:intent]
3070
+ begin
3071
+ run_intent_check(flags[:intent], store: normalize_intent_store_scope(flags[:store]),
3072
+ disposition: flags[:disposition])
3073
+ rescue StandardError => e
3074
+ $stderr.puts "doctor: #{e.message}"
3075
+ exit 2
3076
+ end
3077
+ elsif !flags[:store].nil?
2576
3078
  run_store_checks(flags[:store])
2577
3079
  elsif flags[:core]
2578
3080
  run_core_checks(flags[:agent])