@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.
- package/PLASTIC.md +75 -535
- package/README.md +3 -2
- package/agents/plastic-intent-curator.md +2 -2
- package/deprecations.yml +10 -2
- package/hooks/{links-gate → edit-gates} +1 -1
- package/hooks/hooks.json +4 -44
- package/hooks/power-tools +8 -0
- package/package.json +1 -2
- package/scripts/codex-hook +17 -22
- package/scripts/doctor.rb +688 -186
- package/scripts/end-intent +65 -22
- package/scripts/hook-code-gate +13 -28
- package/scripts/hook-create-gate +10 -57
- package/scripts/hook-edit-gates +58 -0
- package/scripts/hook-links-gate +8 -31
- package/scripts/hook-lock-gate +13 -58
- package/scripts/hook-power-tools +38 -0
- package/scripts/hook-savepoint-pre +8 -11
- package/scripts/lib/bridge.rb +2 -2
- package/scripts/lib/edit_gates.rb +342 -0
- package/scripts/lib/hook_registry.rb +61 -29
- package/scripts/lib/installer_core.rb +100 -18
- package/scripts/lib/outcome_guard.rb +38 -0
- package/scripts/lib/qmd_hook.rb +21 -41
- package/scripts/lib/qmd_sync.rb +0 -15
- package/scripts/lib/revisions_writer.rb +1 -1
- package/scripts/maintenance-run +56 -2
- package/scripts/restore-intent-v1 +16 -11
- package/skills/auto/SKILL.md +15 -4
- package/skills/auto/evals/evals.json +2 -2
- package/skills/conventions/SKILL.md +31 -0
- package/skills/conventions/references/completion-and-done.md +40 -0
- package/skills/conventions/references/gates-and-enforcement.md +23 -0
- package/skills/conventions/references/knowledge-graph.md +47 -0
- package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
- package/skills/conventions/references/locks-and-worktrees.md +113 -0
- package/skills/conventions/references/maintenance-and-revisions.md +170 -0
- package/skills/conventions/references/roadmaps.md +44 -0
- package/skills/conventions/references/tiers-and-dispatch.md +134 -0
- package/skills/doctor/SKILL.md +59 -19
- package/skills/intent-brainstorming/SKILL.md +4 -0
- package/skills/intent-continuing/SKILL.md +4 -0
- package/skills/intent-creating/SKILL.md +7 -2
- package/skills/intent-ending/SKILL.md +20 -6
- package/skills/intent-executing/SKILL.md +10 -0
- package/skills/intent-linking/SKILL.md +4 -0
- package/skills/intent-locking/SKILL.md +4 -0
- package/skills/intent-planning/SKILL.md +7 -0
- package/skills/intent-savepoint/SKILL.md +4 -0
- package/skills/intent-speccing/SKILL.md +4 -0
- package/skills/intent-starting/SKILL.md +10 -0
- package/skills/project-creating/references/project-scaffolding.md +2 -2
- package/skills/roadmap/SKILL.md +4 -0
- package/skills/roadmap-continuing/SKILL.md +4 -0
- package/skills/skill-creating/SKILL.md +3 -0
- package/skills/skill-creating/references/defaults-first.md +23 -0
- package/skills/skill-creating/references/hooks.md +4 -1
- package/skills/store-curating/SKILL.md +8 -0
- package/skills/store-indexing/SKILL.md +8 -0
- package/skills/tutorial/references/track-2-auto.md +2 -3
- package/templates/agents.md +8 -0
- package/PLASTIC-reference.md +0 -138
- package/hooks/code-gate +0 -27
- package/hooks/create-gate +0 -3
- package/hooks/lock-gate +0 -21
- package/hooks/qmd-search +0 -8
- package/hooks/retrieval-gate +0 -10
- package/hooks/savepoint-pre +0 -10
- package/scripts/hook-qmd-search +0 -44
- package/scripts/hook-retrieval-gate +0 -148
- package/scripts/lib/legacy_bookend_amnesty.rb +0 -35
- 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/
|
|
29
|
+
require_relative "lib/outcome_guard"
|
|
30
30
|
require_relative "lib/skill_lint"
|
|
31
31
|
require_relative "lib/config_asks"
|
|
32
|
+
require_relative "lib/power_tools"
|
|
32
33
|
|
|
33
34
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
34
35
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -48,11 +49,6 @@ class Doctor
|
|
|
48
49
|
# (intent 60). Alias it here so the two can never drift.
|
|
49
50
|
REQUIRED_FRONTMATTER_FIELDS = IntentValidator::REQUIRED_FIELDS
|
|
50
51
|
|
|
51
|
-
# Single source of truth for the pre-161 legacy Done-bookend amnesty list
|
|
52
|
-
# lives in LegacyBookendAmnesty (intent 170a). Alias it here so the two can
|
|
53
|
-
# never drift.
|
|
54
|
-
LEGACY_BOOKEND_AMNESTY = LegacyBookendAmnesty::LIST
|
|
55
|
-
|
|
56
52
|
CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit].freeze
|
|
57
53
|
|
|
58
54
|
# Launchers the installer places in the agent's hooks dir that are NOT hooks
|
|
@@ -93,10 +89,9 @@ class Doctor
|
|
|
93
89
|
end
|
|
94
90
|
|
|
95
91
|
def initialize(plastic_home: DEFAULT_PLASTIC_HOME, agents: DEFAULT_AGENTS,
|
|
96
|
-
|
|
92
|
+
runner: Doctor.default_runner)
|
|
97
93
|
@plastic_home = plastic_home
|
|
98
94
|
@agents = agents
|
|
99
|
-
@bookend_amnesty = bookend_amnesty
|
|
100
95
|
@runner = runner
|
|
101
96
|
end
|
|
102
97
|
|
|
@@ -112,6 +107,8 @@ class Doctor
|
|
|
112
107
|
# :global — --store global
|
|
113
108
|
# "<slug>" — --store <slug> (a single project)
|
|
114
109
|
store = nil
|
|
110
|
+
intent = nil
|
|
111
|
+
disposition = nil
|
|
115
112
|
|
|
116
113
|
i = 0
|
|
117
114
|
while i < argv.length
|
|
@@ -127,6 +124,12 @@ class Doctor
|
|
|
127
124
|
when "--core"
|
|
128
125
|
core = true
|
|
129
126
|
i += 1
|
|
127
|
+
when "--intent"
|
|
128
|
+
intent = argv[i + 1]
|
|
129
|
+
i += 2
|
|
130
|
+
when "--disposition"
|
|
131
|
+
disposition = argv[i + 1]
|
|
132
|
+
i += 2
|
|
130
133
|
when "--store"
|
|
131
134
|
nxt = argv[i + 1]
|
|
132
135
|
if nxt && !nxt.start_with?("-")
|
|
@@ -144,7 +147,7 @@ class Doctor
|
|
|
144
147
|
end
|
|
145
148
|
end
|
|
146
149
|
|
|
147
|
-
{ agent: agent, help: help, core: core, store: store }
|
|
150
|
+
{ agent: agent, help: help, core: core, store: store, intent: intent, disposition: disposition }
|
|
148
151
|
end
|
|
149
152
|
|
|
150
153
|
def show_help
|
|
@@ -163,6 +166,11 @@ class Doctor
|
|
|
163
166
|
--store [WHICH] Run only the store/conventions checks. WHICH may be:
|
|
164
167
|
global (global store only), a project slug (that project
|
|
165
168
|
only), or omitted (all stores). 3-state pass/warn/fail.
|
|
169
|
+
--intent ID Per-intent structure gate at intent-end (intent 222): one
|
|
170
|
+
closing intent only, never a store sweep. Pair with
|
|
171
|
+
--store <key> to disambiguate an id that collides across
|
|
172
|
+
stores, and --disposition delivered|abandoned to fold in
|
|
173
|
+
the outcome.md disposition check. 3-state pass/warn/fail.
|
|
166
174
|
-h, --help Show this help
|
|
167
175
|
|
|
168
176
|
Output:
|
|
@@ -366,6 +374,45 @@ class Doctor
|
|
|
366
374
|
checks
|
|
367
375
|
end
|
|
368
376
|
|
|
377
|
+
# --- Check category: global store availability (core-only, D1) ---
|
|
378
|
+
#
|
|
379
|
+
# Narrow, core-appropriate version of check_global_store: presence/readability only, zero
|
|
380
|
+
# content scanning. check_global_store (above) is store/full-scope and includes
|
|
381
|
+
# orphaned_intents / ghost_references, both explicitly forbidden at core by D1.
|
|
382
|
+
def check_global_store_available
|
|
383
|
+
checks = []
|
|
384
|
+
|
|
385
|
+
if File.directory?(plastic_home)
|
|
386
|
+
checks << check(
|
|
387
|
+
category: "global_store", name: "global_store_reachable", status: "pass",
|
|
388
|
+
message: "Global store directory reachable at #{tilde(plastic_home)}"
|
|
389
|
+
)
|
|
390
|
+
else
|
|
391
|
+
checks << check(
|
|
392
|
+
category: "global_store", name: "global_store_reachable", status: "fail",
|
|
393
|
+
message: "Global store directory not found at #{tilde(plastic_home)}",
|
|
394
|
+
fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
|
|
395
|
+
)
|
|
396
|
+
return checks
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
index_path = File.join(plastic_home, "INDEX.md")
|
|
400
|
+
if File.exist?(index_path)
|
|
401
|
+
checks << check(
|
|
402
|
+
category: "global_store", name: "global_index_reachable", status: "pass",
|
|
403
|
+
message: "INDEX.md exists"
|
|
404
|
+
)
|
|
405
|
+
else
|
|
406
|
+
checks << check(
|
|
407
|
+
category: "global_store", name: "global_index_reachable", status: "fail",
|
|
408
|
+
message: "INDEX.md not found at #{tilde(index_path)}",
|
|
409
|
+
fixable: true, fix_hint: "Run the Plastic installer to bootstrap the store"
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
checks
|
|
414
|
+
end
|
|
415
|
+
|
|
369
416
|
# --- Check category 2: Conventions ---
|
|
370
417
|
|
|
371
418
|
# When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
|
|
@@ -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
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
658
|
+
# Per-directory done-signal findings for ONE intent dir (intent 222): the shared unit both
|
|
659
|
+
# check_done_signals's store-wide loop (211's territory, unchanged severities) and the new
|
|
660
|
+
# per-intent check (check_intent_end) call, so the two surfaces can never independently
|
|
661
|
+
# drift on what counts as a phantom line or a completeness gap. Returns
|
|
662
|
+
# {conflict:, phantom:, gap:, stalled:} where conflict/phantom/stalled are nil or a finding
|
|
663
|
+
# string, and gap is an ARRAY (0, 1, or 2 strings): the original inline code pushed the
|
|
664
|
+
# "outcome missing" gap and the "audit echo missing" gap as two SEPARATE, independent `if`
|
|
665
|
+
# blocks (never elsif), so a single terminal dir missing BOTH can legitimately contribute
|
|
666
|
+
# two distinct gap strings in the same pass; collapsing that into one nilable field would
|
|
667
|
+
# silently drop one of the two on a dir where both are true (verified against
|
|
668
|
+
# test/doctor_done_signals_test.rb's fixtures before this extraction, per plan.md Step 3).
|
|
669
|
+
def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:)
|
|
670
|
+
outcome = File.join(dir, "outcome.md")
|
|
671
|
+
outcome_real = Bridge.stage_file_present?(outcome)
|
|
672
|
+
findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], stalled: nil }
|
|
673
|
+
|
|
674
|
+
# HARD conflict: the deliverable exists but INDEX still says Active. This
|
|
675
|
+
# is the one true INDEX-wins disagreement, so it stays a fail.
|
|
676
|
+
if active && outcome_real
|
|
677
|
+
findings[:conflict] = "#{label}: outcome.md is real but the intent is still under ## Active " \
|
|
678
|
+
"(INDEX is canonical - move it to its terminal section or revert outcome.md)"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Savepoint truthfulness (advisory, never fail; intent 134). Terminal phantom lines are
|
|
682
|
+
# ALWAYS report-only (immutable history), with no suppression by id or scope (intent 211:
|
|
683
|
+
# the generic replacement for the frozen 170a amnesty list is simply "never suppress" -
|
|
684
|
+
# the message already labels a terminal phantom report-only, so dropping the id-based
|
|
685
|
+
# suppression is strictly more general and needs no per-store state at all).
|
|
686
|
+
phantom_lines = Bridge.savepoint_phantom_lines(dir)
|
|
687
|
+
if phantom_lines.any?
|
|
688
|
+
detail = phantom_lines.map { |line, reason| "#{line} (#{reason})" }.join("; ")
|
|
689
|
+
scope_note = terminal ? "terminal in INDEX, report-only (immutable history)" : "live intent, auto-rebuildable"
|
|
690
|
+
findings[:phantom] = "#{label}: #{phantom_lines.size} phantom savepoint line(s) contradicted by " \
|
|
691
|
+
"disk, #{scope_note}: #{detail}"
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Delivery-claim gap (intent 211, 219 D6): a missing/placeholder outcome.md on a terminal
|
|
695
|
+
# intent is never fabricated, so this is unrepairable by construction. Reported informational
|
|
696
|
+
# only (signals_complete, always status "pass" - see check_done_signals).
|
|
697
|
+
if terminal && !outcome_real
|
|
698
|
+
state = File.exist?(outcome) ? "still a placeholder" : "missing"
|
|
699
|
+
findings[:gap] << "#{label}: terminal in INDEX but outcome.md is #{state} " \
|
|
700
|
+
"(delivery claim - never fabricated)"
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
if terminal
|
|
704
|
+
# Operational gap (intent 211, 219 D6): savepoint.md missing entirely (not previously
|
|
705
|
+
# checked at all) or present but missing the Done bookend - both are minimally
|
|
706
|
+
# reconstructible via maintenance-run --tool rebuild-savepoint, so this is repairable and
|
|
707
|
+
# reported as a fixable warn (savepoint_operational).
|
|
708
|
+
savepoint = File.join(dir, "savepoint.md")
|
|
709
|
+
if !File.exist?(savepoint)
|
|
710
|
+
findings[:operational_gap] << "#{label}: terminal in INDEX but savepoint.md is missing " \
|
|
711
|
+
"entirely (operational - reconstructible)"
|
|
712
|
+
elsif File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
|
|
713
|
+
findings[:operational_gap] << "#{label}: terminal in INDEX but savepoint.md has no " \
|
|
714
|
+
"`Done delivered|abandoned` line (operational - reconstructible)"
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Stalled completion: unchanged, never consulted amnesty.
|
|
718
|
+
if File.exist?(Lock.path(dir))
|
|
719
|
+
note = Lock.fresh?(dir) ? "delivery.lock still present (post-done window not closed)"
|
|
720
|
+
: "delivery.lock is present and STALE"
|
|
721
|
+
findings[:stalled] = "#{label}: #{note} - the End tail did not finish"
|
|
722
|
+
end
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
findings
|
|
726
|
+
end
|
|
634
727
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
# resurface through this advisory. The suppression is scope-qualified exactly like the
|
|
642
|
-
# audit-echo amnesty; every live intent and every non-amnestied terminal still fires.
|
|
643
|
-
phantom_lines = Bridge.savepoint_phantom_lines(dir)
|
|
644
|
-
phantom_id = dirname.split("--", 2).first
|
|
645
|
-
phantom_amnestied = terminal && @bookend_amnesty.fetch(store[:scope], []).include?(phantom_id)
|
|
646
|
-
if phantom_lines.any? && !phantom_amnestied
|
|
647
|
-
detail = phantom_lines.map { |line, reason| "#{line} (#{reason})" }.join("; ")
|
|
648
|
-
scope_note = terminal ? "terminal in INDEX, report-only (immutable history)" : "live intent, auto-rebuildable"
|
|
649
|
-
phantoms << "#{label}: #{phantom_lines.size} phantom savepoint line(s) contradicted by " \
|
|
650
|
-
"disk, #{scope_note}: #{detail}"
|
|
651
|
-
end
|
|
728
|
+
def check_done_signals(scopes: nil)
|
|
729
|
+
conflicts = []
|
|
730
|
+
gaps = [] # delivery-claim (outcome.md) gaps only - legacy, informational
|
|
731
|
+
operational_gaps = [] # savepoint gaps (missing file, or missing Done echo) - repairable
|
|
732
|
+
stalled = []
|
|
733
|
+
phantoms = []
|
|
652
734
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
state = File.exist?(outcome) ? "still a placeholder" : "missing"
|
|
658
|
-
gaps << "#{label}: terminal in INDEX but outcome.md is #{state} " \
|
|
659
|
-
"(author outcome.md with the disposition header)"
|
|
660
|
-
end
|
|
735
|
+
done_signal_stores(scopes).each do |store|
|
|
736
|
+
index_sections_by_dir(store[:index]).each do |dirname, in_sections|
|
|
737
|
+
dir = File.join(store[:store_dir], dirname)
|
|
738
|
+
next unless File.directory?(dir)
|
|
661
739
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
# `Done delivered|abandoned` line. Missing on legacy history -> advisory
|
|
666
|
-
# gap, unless the (scope, id) is on the frozen pre-161 amnesty list
|
|
667
|
-
# (intent 170a). The amnesty is scope-qualified: an id on the list for
|
|
668
|
-
# one scope does not grandfather the same id in another scope.
|
|
669
|
-
savepoint = File.join(dir, "savepoint.md")
|
|
670
|
-
if File.exist?(savepoint) && File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
|
|
671
|
-
id = dirname.split("--", 2).first
|
|
672
|
-
amnestied = @bookend_amnesty.fetch(store[:scope], []).include?(id)
|
|
673
|
-
unless amnestied
|
|
674
|
-
gaps << "#{label}: terminal in INDEX but savepoint.md has no " \
|
|
675
|
-
"`Done delivered|abandoned` line (audit echo missing)"
|
|
676
|
-
end
|
|
677
|
-
end
|
|
740
|
+
terminal = (in_sections & ["Completed", "Abandoned"]).any?
|
|
741
|
+
active = in_sections.include?("Active") && !terminal
|
|
742
|
+
label = "#{store[:scope]} store/#{dirname}"
|
|
678
743
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
744
|
+
findings = done_signal_findings_for_dir(
|
|
745
|
+
dir, label: label, scope: store[:scope], dirname: dirname, terminal: terminal, active: active
|
|
746
|
+
)
|
|
747
|
+
conflicts << findings[:conflict] if findings[:conflict]
|
|
748
|
+
phantoms << findings[:phantom] if findings[:phantom]
|
|
749
|
+
gaps.concat(findings[:gap])
|
|
750
|
+
operational_gaps.concat(findings[:operational_gap])
|
|
751
|
+
stalled << findings[:stalled] if findings[:stalled]
|
|
687
752
|
end
|
|
753
|
+
end
|
|
688
754
|
|
|
689
|
-
|
|
755
|
+
checks = []
|
|
690
756
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
end
|
|
757
|
+
# signals_agree: unchanged.
|
|
758
|
+
if conflicts.empty?
|
|
759
|
+
checks << check(
|
|
760
|
+
category: "done_signals", name: "signals_agree", status: "pass",
|
|
761
|
+
message: "No done-signal conflicts (no intent has a real outcome.md while still Active)"
|
|
762
|
+
)
|
|
763
|
+
else
|
|
764
|
+
checks << check(
|
|
765
|
+
category: "done_signals", name: "signals_agree", status: "fail",
|
|
766
|
+
message: "#{conflicts.size} done-signal conflict#{conflicts.size == 1 ? "" : "s"} " \
|
|
767
|
+
"(INDEX ## Completed/## Abandoned is canonical; a real outcome.md must not stay Active)",
|
|
768
|
+
details: conflicts, fixable: true,
|
|
769
|
+
fix_hint: "Reconcile to INDEX (canonical): move the intent to its terminal section, or revert " \
|
|
770
|
+
"outcome.md to a placeholder. Deliverable-exists but still-Active is the one done-signal " \
|
|
771
|
+
"state that must never persist."
|
|
772
|
+
)
|
|
773
|
+
end
|
|
709
774
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
end
|
|
775
|
+
# signals_complete (intent 211, 219 D6, repurposed): outcome.md delivery-claim gaps only.
|
|
776
|
+
# Never fabricated, so there is no legitimate fix to hint at - always status "pass",
|
|
777
|
+
# informational (matches check_skill_lint's advisory precedent: count + details, no
|
|
778
|
+
# fix_hint/fixable, never affects doctor's exit code).
|
|
779
|
+
if gaps.empty?
|
|
780
|
+
checks << check(
|
|
781
|
+
category: "done_signals", name: "signals_complete", status: "pass",
|
|
782
|
+
message: "Every terminal intent carries a real outcome.md"
|
|
783
|
+
)
|
|
784
|
+
else
|
|
785
|
+
checks << check(
|
|
786
|
+
category: "done_signals", name: "signals_complete", status: "pass",
|
|
787
|
+
message: "#{gaps.size} terminal intent#{gaps.size == 1 ? "" : "s"} missing a real outcome.md " \
|
|
788
|
+
"(delivery claim - never fabricated; informational only, does not affect doctor's exit code)",
|
|
789
|
+
details: gaps
|
|
790
|
+
)
|
|
791
|
+
end
|
|
728
792
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
793
|
+
# savepoint_operational (intent 211, NEW): missing savepoint.md or missing Done echo -
|
|
794
|
+
# repairable via maintenance-run --tool rebuild-savepoint, so this stays warn+fixable.
|
|
795
|
+
if operational_gaps.empty?
|
|
796
|
+
checks << check(
|
|
797
|
+
category: "done_signals", name: "savepoint_operational", status: "pass",
|
|
798
|
+
message: "No terminal intent is missing an operational savepoint.md or its Done echo"
|
|
799
|
+
)
|
|
800
|
+
else
|
|
801
|
+
checks << check(
|
|
802
|
+
category: "done_signals", name: "savepoint_operational", status: "warn",
|
|
803
|
+
message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
|
|
804
|
+
"missing an operational savepoint.md or its Done echo (reconstructible)",
|
|
805
|
+
details: operational_gaps, fixable: true,
|
|
806
|
+
fix_hint: "Reconstruct the minimal two-line started/Done echo via " \
|
|
807
|
+
"`maintenance-run --tool rebuild-savepoint --intent <id> --apply` (197-conformant: " \
|
|
808
|
+
"receipt-before-write via RevisionsWriter, one intent per invocation, owner-approval-gated)."
|
|
809
|
+
)
|
|
810
|
+
end
|
|
745
811
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
"124a manual Done-bookend repair."
|
|
763
|
-
)
|
|
764
|
-
end
|
|
812
|
+
if stalled.empty?
|
|
813
|
+
checks << check(
|
|
814
|
+
category: "done_signals", name: "stalled_completion", status: "pass",
|
|
815
|
+
message: "No stalled completions (every terminal intent released its delivery lock)"
|
|
816
|
+
)
|
|
817
|
+
else
|
|
818
|
+
checks << check(
|
|
819
|
+
category: "done_signals", name: "stalled_completion", status: "warn",
|
|
820
|
+
message: "#{stalled.size} stalled completion#{stalled.size == 1 ? "" : "s"} " \
|
|
821
|
+
"(terminal in INDEX but the End tail did not finish)",
|
|
822
|
+
details: stalled, fixable: true,
|
|
823
|
+
fix_hint: "Finish the End tail via stale-lock reclaim: run /plastic-doctor reclaim the lock, " \
|
|
824
|
+
"then complete the tail (Worktree.release -> Lock.release -> purge -> QMD reindex " \
|
|
825
|
+
"last). This FINISHES a completion; it is NOT a reactivation of a done intent."
|
|
826
|
+
)
|
|
827
|
+
end
|
|
765
828
|
|
|
766
|
-
|
|
829
|
+
# savepoint_truthful: advisory only (intent 134), never fails. No amnesty suppression left.
|
|
830
|
+
if phantoms.empty?
|
|
831
|
+
checks << check(
|
|
832
|
+
category: "done_signals", name: "savepoint_truthful", status: "pass",
|
|
833
|
+
message: "No savepoint.md lines are contradicted by disk (phantom-line check clean)"
|
|
834
|
+
)
|
|
835
|
+
else
|
|
836
|
+
checks << check(
|
|
837
|
+
category: "done_signals", name: "savepoint_truthful", status: "warn",
|
|
838
|
+
message: "#{phantoms.size} intent#{phantoms.size == 1 ? "" : "s"} carry savepoint.md " \
|
|
839
|
+
"line(s) contradicted by disk (advisory; terminal history stays report-only)",
|
|
840
|
+
details: phantoms, fixable: true,
|
|
841
|
+
fix_hint: "For a live (Active) intent, run plastic-intent-savepoint to rebuild via " \
|
|
842
|
+
"Bridge.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
|
|
843
|
+
"a phantom there stays advisory unless an explicit human grant authorizes the " \
|
|
844
|
+
"124a manual Done-bookend repair."
|
|
845
|
+
)
|
|
767
846
|
end
|
|
768
847
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
772
|
-
# ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
|
|
773
|
-
# run) filters only the REPORTED findings by origin scope.
|
|
774
|
-
def links_projection_check(scopes: nil)
|
|
775
|
-
all_dirs = all_intent_dirs
|
|
848
|
+
checks
|
|
849
|
+
end
|
|
776
850
|
|
|
851
|
+
# Build the cross-store node maps (basename + label per store) + relocation map from ALL
|
|
852
|
+
# stores (intent 222): the one shared build both the store-wide links_projection_check and
|
|
853
|
+
# the new per-intent intent_links_projection_check_for call, so the two can never disagree
|
|
854
|
+
# on how a ref resolves.
|
|
855
|
+
def build_links_projection_context
|
|
856
|
+
all_dirs = all_intent_dirs
|
|
777
857
|
store_index = seeded_store_index
|
|
778
858
|
node_index = Hash.new { |h, k| h[k] = {} }
|
|
779
859
|
intents = [] # { scope:, id:, sources:, chain:, path: }
|
|
@@ -796,6 +876,20 @@ class Doctor
|
|
|
796
876
|
end
|
|
797
877
|
|
|
798
878
|
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
879
|
+
{ store_index: store_index, node_index: node_index, relocation_map: relocation_map, intents: intents }
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
# Build the cross-store node maps (basename + label per store) + relocation map
|
|
883
|
+
# from ALL stores, then for every intent compute its canonical `## Links`
|
|
884
|
+
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
885
|
+
# ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
|
|
886
|
+
# run) filters only the REPORTED findings by origin scope.
|
|
887
|
+
def links_projection_check(scopes: nil)
|
|
888
|
+
ctx = build_links_projection_context
|
|
889
|
+
store_index = ctx[:store_index]
|
|
890
|
+
node_index = ctx[:node_index]
|
|
891
|
+
relocation_map = ctx[:relocation_map]
|
|
892
|
+
intents = ctx[:intents]
|
|
799
893
|
|
|
800
894
|
findings = []
|
|
801
895
|
intents.each do |node|
|
|
@@ -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
|
-
"(
|
|
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
|
-
"
|
|
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
|
-
|
|
2791
|
+
expected = collection ? [collection] : (status[:expected] || [])
|
|
2792
|
+
missing = collection ? (Array(status[:missing]) & expected) : (status[:missing] || [])
|
|
2399
2793
|
|
|
2400
|
-
if
|
|
2794
|
+
if missing.empty?
|
|
2401
2795
|
checks << check(
|
|
2402
2796
|
category: "qmd", name: "collections", status: "pass",
|
|
2403
|
-
message: "
|
|
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: "#{
|
|
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
|
|
2486
|
-
#
|
|
2487
|
-
# "fail" (and "warn" is never
|
|
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
|
-
|
|
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"]) +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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])
|