@zalom/plastic 1.0.0-alpha.25 → 1.0.0-alpha.27

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 CHANGED
@@ -184,6 +184,6 @@ Detailed conventions live inside the skills that use them, not in this file.
184
184
  | Projects, hubs | `plastic-creating-project` | hubs, project stores |
185
185
  | Index maintenance | `plastic-managing-index` | — |
186
186
  | Releases, deprecations | `plastic-releasing` | deprecation process |
187
- | Health diagnostics | `plastic-doctor` | gate enforcement, stuck detection |
187
+ | Health diagnostics | `plastic-doctor` | three scopes: `--core` (binary install-integrity check, runs on SessionStart), `--store [global\|<slug>]` (per-store check, runs on dashboard load), no flag = full check (runs after every update); gate enforcement, stuck detection |
188
188
  | Writing agent instructions | `plastic-writing-instructions` | agentskills.io spec |
189
189
  | Evaluating skills, evals | `plastic-evaluating-skills` | eval methodology, convention checks |
package/README.md CHANGED
@@ -114,6 +114,11 @@ Or say "auto" to let the agent handle the full lifecycle autonomously.
114
114
 
115
115
  All conventions live in `AGENTS.md`, distributed to `~/.plastic/AGENTS.md`
116
116
  during installation. Run `plastic-doctor` to check installation health.
117
+ `plastic-doctor --core` runs a binary install-integrity check (compares files
118
+ against the install manifests; pass or error). `plastic-doctor --store` checks
119
+ store state (intents, INDEX sections, conventions) and can be scoped to
120
+ `global` or a project slug. The full `plastic-doctor` runs all checks and is
121
+ run automatically after every update.
117
122
 
118
123
  ## License
119
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-alpha.25",
3
+ "version": "1.0.0-alpha.27",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  require "json"
23
23
  require "yaml"
24
24
  require "date"
25
+ require_relative "doctor"
25
26
 
26
27
  PLASTIC_HOME = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
27
28
 
@@ -449,6 +450,34 @@ def render_all(records)
449
450
  out.join("\n") + "\n"
450
451
  end
451
452
 
453
+ # ---------------------------------------------------------------------------
454
+ # Store health — runs doctor's scoped store check on dashboard load.
455
+ #
456
+ # Each board load runs `doctor --store <scope>` (global board -> :global,
457
+ # project board -> the slug) and surfaces a compact store-health line in the
458
+ # payload. Invoked IN-PROCESS (Doctor.new + run_store_checks) rather than
459
+ # shelling out: it is hermetic for tests (same PLASTIC_HOME), faster (no second
460
+ # Ruby boot), and avoids parsing a subprocess's JSON. Non-fatal by contract: a
461
+ # warn/fail result is data only and never crashes the board or changes its exit.
462
+ # ---------------------------------------------------------------------------
463
+
464
+ def store_health(scope)
465
+ result = Doctor.new(plastic_home: PLASTIC_HOME).run_store_checks(scope)
466
+ failing = (result[:checks] || []).reject { |c| c[:status] == "pass" }
467
+ .map { |c| c[:name] }
468
+ {
469
+ scope: scope.is_a?(Symbol) ? scope.to_s : scope,
470
+ status: result[:status],
471
+ summary: result[:summary],
472
+ failing_checks: failing,
473
+ }
474
+ rescue StandardError => e
475
+ # Never let a store-health probe take down the dashboard.
476
+ { scope: scope.is_a?(Symbol) ? scope.to_s : scope,
477
+ status: "warn", summary: { pass: 0, warn: 1, fail: 0, total: 1 },
478
+ failing_checks: ["store_health_probe_error"], error: e.message }
479
+ end
480
+
452
481
  # ---------------------------------------------------------------------------
453
482
  # Markdown-board data payload (intent 37) — heavy side; the skill fills a
454
483
  # Markdown template from this and presents it. Deterministic, golden-tested.
@@ -549,6 +578,7 @@ def render_data_global(records)
549
578
  matrix_pool = global.select { |r| actionable?(r) && r[:status] != "active" }
550
579
  projs = project_summaries(records)
551
580
  { mode: "global", date: today.to_s,
581
+ store_health: store_health(:global),
552
582
  recently_worked: recently_worked(records),
553
583
  matrix: matrix_data(matrix_pool),
554
584
  counts: counts_of(global),
@@ -564,6 +594,7 @@ def render_data_project(records, slug)
564
594
  scoped = records.select { |r| r[:scope] == scope }
565
595
  matrix_pool = scoped.select { |r| r[:status] == "future" }
566
596
  { mode: "project", date: today.to_s, slug: slug,
597
+ store_health: store_health(slug),
567
598
  description: short_description(scope),
568
599
  recently_worked: recently_worked(records, project_scope: scope),
569
600
  matrix: matrix_data(matrix_pool),
@@ -619,7 +650,14 @@ def main(argv)
619
650
  if json
620
651
  subset = mode == "project" ? records.select { |r| r[:scope] == "project:#{slug}" } : records
621
652
  label = mode == "project" ? "project:#{slug}" : "all"
622
- puts JSON.pretty_generate(render_json(subset, label))
653
+ payload = render_json(subset, label)
654
+ # Run the scoped store check on load, mirroring the --data board path:
655
+ # project board -> the slug; global/continue board -> :global. The `all`
656
+ # manifest spans every store, so its store-health probe is left to the
657
+ # per-scope boards (keeping the all-scopes auto-mode contract stable).
658
+ payload[:store_health] = store_health(slug) if mode == "project"
659
+ payload[:store_health] = store_health(:global) if mode == "continue"
660
+ puts JSON.pretty_generate(payload)
623
661
  return 0
624
662
  end
625
663
 
package/scripts/doctor.rb CHANGED
@@ -13,6 +13,9 @@ require "json"
13
13
  require "yaml"
14
14
  require "time"
15
15
  require "date"
16
+ require "digest"
17
+
18
+ require_relative "lib/qmd_sync"
16
19
 
17
20
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
18
21
  # run it hermetically (no eval, no global-constant rewriting).
@@ -63,6 +66,12 @@ class Doctor
63
66
  agent = "claude"
64
67
  help = false
65
68
  core = false
69
+ # store flag representation:
70
+ # nil — --store not given
71
+ # :all — --store with no value (check every store)
72
+ # :global — --store global
73
+ # "<slug>" — --store <slug> (a single project)
74
+ store = nil
66
75
 
67
76
  i = 0
68
77
  while i < argv.length
@@ -78,6 +87,15 @@ class Doctor
78
87
  when "--core"
79
88
  core = true
80
89
  i += 1
90
+ when "--store"
91
+ nxt = argv[i + 1]
92
+ if nxt && !nxt.start_with?("-")
93
+ store = (nxt == "global") ? :global : nxt
94
+ i += 2
95
+ else
96
+ store = :all
97
+ i += 1
98
+ end
81
99
  when "--help", "-h"
82
100
  help = true
83
101
  i += 1
@@ -86,7 +104,7 @@ class Doctor
86
104
  end
87
105
  end
88
106
 
89
- { agent: agent, help: help, core: core }
107
+ { agent: agent, help: help, core: core, store: store }
90
108
  end
91
109
 
92
110
  def show_help
@@ -99,8 +117,12 @@ class Doctor
99
117
 
100
118
  Options:
101
119
  --agent NAME Agent to check: claude (default), codex, hermes
102
- --core Fast runtime-liveness check only (hooks, scripts, core files);
103
- skips the slow store/conventions/project inventory walks.
120
+ --core Binary core sync check: verifies agent registration, core
121
+ files, and that every manifest-tracked file matches its
122
+ recorded SHA256. Exits 0 (pass) or 2 (fail); never warn.
123
+ --store [WHICH] Run only the store/conventions checks. WHICH may be:
124
+ global (global store only), a project slug (that project
125
+ only), or omitted (all stores). 3-state pass/warn/fail.
104
126
  -h, --help Show this help
105
127
 
106
128
  Output:
@@ -301,10 +323,14 @@ class Doctor
301
323
 
302
324
  # --- Check category 2: Conventions ---
303
325
 
304
- def check_conventions
326
+ # When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
327
+ # ["project:plastic"]), only intents whose :scope is in that list are checked.
328
+ # When nil (the default, used by the full run), every intent is checked.
329
+ def check_conventions(scopes: nil)
305
330
  checks = []
306
331
 
307
332
  intent_dirs = all_intent_dirs
333
+ intent_dirs = intent_dirs.select { |d| scopes.include?(d[:scope]) } unless scopes.nil?
308
334
  dirname_pattern = /^\w+--[\w-]+$/
309
335
 
310
336
  # intent_dirname
@@ -639,6 +665,84 @@ class Doctor
639
665
  checks
640
666
  end
641
667
 
668
+ # --- Check category: manifest sync (binary core integrity) ---
669
+
670
+ # Verify, for BOTH the global manifest and the agent-side manifest, that every
671
+ # file listed exists and its current SHA256 matches the recorded hash.
672
+ # - GLOBAL manifest: <plastic_home>/manifest.json
673
+ # - AGENT-side manifest: claude -> <dir>/plastic/manifest.json
674
+ # other -> <dir>/plastic-manifest.json
675
+ # Manifest format: { "version", "created", "files": { abs_path => sha256 } }.
676
+ # A missing manifest is a fail; any missing/mismatched listed file is a fail;
677
+ # otherwise a single pass per manifest.
678
+ def check_manifest_sync(agent_key)
679
+ checks = []
680
+
681
+ global_manifest = File.join(plastic_home, "manifest.json")
682
+ checks << verify_manifest(global_manifest, "global")
683
+
684
+ agent_dir = agents[agent_key][:dir]
685
+ agent_manifest = if agent_key == "claude"
686
+ File.join(agent_dir, "plastic", "manifest.json")
687
+ else
688
+ File.join(agent_dir, "plastic-manifest.json")
689
+ end
690
+ checks << verify_manifest(agent_manifest, "agent")
691
+
692
+ checks
693
+ end
694
+
695
+ # Check one manifest file. Returns a single check (pass or fail).
696
+ def verify_manifest(manifest_path, label)
697
+ unless File.exist?(manifest_path)
698
+ return check(
699
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
700
+ message: "#{label} core manifest missing — re-run the Plastic installer",
701
+ details: [tilde(manifest_path)],
702
+ fixable: true, fix_hint: "Re-run the Plastic installer"
703
+ )
704
+ end
705
+
706
+ data = read_json_safe(manifest_path)
707
+ files = data.is_a?(Hash) ? data["files"] : nil
708
+ unless files.is_a?(Hash)
709
+ return check(
710
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
711
+ message: "#{label} core manifest unreadable or malformed — re-run the Plastic installer",
712
+ details: [tilde(manifest_path)],
713
+ fixable: true, fix_hint: "Re-run the Plastic installer"
714
+ )
715
+ end
716
+
717
+ missing = []
718
+ mismatched = []
719
+ files.each do |path, recorded|
720
+ unless File.exist?(path)
721
+ missing << tilde(path)
722
+ next
723
+ end
724
+ actual = Digest::SHA256.file(path).hexdigest
725
+ mismatched << tilde(path) if actual != recorded
726
+ end
727
+
728
+ if missing.empty? && mismatched.empty?
729
+ check(
730
+ category: "manifest_sync", name: "#{label}_manifest", status: "pass",
731
+ message: "#{label} manifest: all #{files.size} tracked file(s) present and matching"
732
+ )
733
+ else
734
+ details = []
735
+ details += missing.map { |p| "missing: #{p}" }
736
+ details += mismatched.map { |p| "modified: #{p}" }
737
+ check(
738
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
739
+ message: "#{label} manifest out of sync: #{missing.size} missing, #{mismatched.size} modified",
740
+ details: details,
741
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore tracked files"
742
+ )
743
+ end
744
+ end
745
+
642
746
  # --- Check category 5: Project stores ---
643
747
 
644
748
  def check_project_stores
@@ -666,135 +770,140 @@ class Doctor
666
770
  return checks
667
771
  end
668
772
 
669
- # Load INDEX.md content for cross-reference checks
670
- index_path = File.join(plastic_home, "INDEX.md")
671
- index_content = File.exist?(index_path) ? File.read(index_path) : ""
672
-
673
773
  projects.each do |slug, project_info|
674
- project_dir = File.join(plastic_home, "projects", slug)
774
+ checks += check_project_store(slug, project_info)
775
+ end
675
776
 
676
- # project_dir_exists
677
- if File.directory?(project_dir)
678
- checks << check(
679
- category: "project_stores", name: "project_dir_exists", status: "pass",
680
- message: "Project directory exists for '#{slug}'"
681
- )
682
- else
683
- checks << check(
684
- category: "project_stores", name: "project_dir_exists", status: "warn",
685
- message: "Project directory missing for '#{slug}'",
686
- details: [tilde(project_dir)],
687
- fixable: true, fix_hint: "Create the project store directory: mkdir -p #{tilde(project_dir)}"
688
- )
689
- end
777
+ checks
778
+ end
690
779
 
691
- # project_index
692
- project_index = File.join(project_dir, "INDEX.md")
693
- if File.exist?(project_index)
694
- checks << check(
695
- category: "project_stores", name: "project_index", status: "pass",
696
- message: "INDEX.md exists for project '#{slug}'"
697
- )
698
- else
699
- checks << check(
700
- category: "project_stores", name: "project_index", status: "warn",
701
- message: "INDEX.md missing for project '#{slug}'",
702
- details: [tilde(project_index)],
703
- fixable: true, fix_hint: "Create INDEX.md in the project store directory"
704
- )
705
- end
780
+ # Per-project validation extracted from check_project_stores so a single
781
+ # project can be checked in isolation (used by `--store <slug>`).
782
+ def check_project_store(slug, project_info)
783
+ checks = []
784
+ project_dir = File.join(plastic_home, "projects", slug)
706
785
 
707
- # project_yml_exists
708
- project_yml_path = File.join(plastic_home, "projects", slug, "project.yml")
709
- project_yml_data = nil
786
+ # project_dir_exists
787
+ if File.directory?(project_dir)
788
+ checks << check(
789
+ category: "project_stores", name: "project_dir_exists", status: "pass",
790
+ message: "Project directory exists for '#{slug}'"
791
+ )
792
+ else
793
+ checks << check(
794
+ category: "project_stores", name: "project_dir_exists", status: "warn",
795
+ message: "Project directory missing for '#{slug}'",
796
+ details: [tilde(project_dir)],
797
+ fixable: true, fix_hint: "Create the project store directory: mkdir -p #{tilde(project_dir)}"
798
+ )
799
+ end
710
800
 
711
- if File.exist?(project_yml_path)
712
- checks << check(
713
- category: "project_stores", name: "project_yml_exists", status: "pass",
714
- message: "project.yml exists for project '#{slug}'"
715
- )
716
- project_yml_data = load_yaml_safe(project_yml_path)
717
- else
718
- checks << check(
719
- category: "project_stores", name: "project_yml_exists", status: "warn",
720
- message: "project.yml missing for project '#{slug}'",
721
- fixable: true, fix_hint: "Create project.yml from template see plastic-creating-project"
722
- )
723
- end
801
+ # project_index
802
+ project_index = File.join(project_dir, "INDEX.md")
803
+ if File.exist?(project_index)
804
+ checks << check(
805
+ category: "project_stores", name: "project_index", status: "pass",
806
+ message: "INDEX.md exists for project '#{slug}'"
807
+ )
808
+ else
809
+ checks << check(
810
+ category: "project_stores", name: "project_index", status: "warn",
811
+ message: "INDEX.md missing for project '#{slug}'",
812
+ details: [tilde(project_index)],
813
+ fixable: true, fix_hint: "Create INDEX.md in the project store directory"
814
+ )
815
+ end
816
+
817
+ # project_yml_exists
818
+ project_yml_path = File.join(plastic_home, "projects", slug, "project.yml")
819
+ project_yml_data = nil
820
+
821
+ if File.exist?(project_yml_path)
822
+ checks << check(
823
+ category: "project_stores", name: "project_yml_exists", status: "pass",
824
+ message: "project.yml exists for project '#{slug}'"
825
+ )
826
+ project_yml_data = load_yaml_safe(project_yml_path)
827
+ else
828
+ checks << check(
829
+ category: "project_stores", name: "project_yml_exists", status: "warn",
830
+ message: "project.yml missing for project '#{slug}'",
831
+ fixable: true, fix_hint: "Create project.yml from template — see plastic-creating-project"
832
+ )
833
+ end
724
834
 
725
- # governing_docs_exist
726
- if project_yml_data.is_a?(Hash) && project_yml_data["governing_docs"].is_a?(Array) && !project_yml_data["governing_docs"].empty?
727
- project_path = project_info.is_a?(Hash) ? project_info["path"] : nil
728
-
729
- if project_path
730
- missing_docs = project_yml_data["governing_docs"].reject do |doc_path|
731
- File.exist?(File.join(project_path, doc_path))
732
- end
733
-
734
- if missing_docs.empty?
735
- checks << check(
736
- category: "project_stores", name: "governing_docs_exist", status: "pass",
737
- message: "All governing docs exist for project '#{slug}'"
738
- )
739
- else
740
- checks << check(
741
- category: "project_stores", name: "governing_docs_exist", status: "warn",
742
- message: "#{missing_docs.size} governing doc(s) missing for project '#{slug}'",
743
- details: missing_docs,
744
- fixable: false
745
- )
746
- end
835
+ # governing_docs_exist
836
+ if project_yml_data.is_a?(Hash) && project_yml_data["governing_docs"].is_a?(Array) && !project_yml_data["governing_docs"].empty?
837
+ project_path = project_info.is_a?(Hash) ? project_info["path"] : nil
838
+
839
+ if project_path
840
+ missing_docs = project_yml_data["governing_docs"].reject do |doc_path|
841
+ File.exist?(File.join(project_path, doc_path))
842
+ end
843
+
844
+ if missing_docs.empty?
845
+ checks << check(
846
+ category: "project_stores", name: "governing_docs_exist", status: "pass",
847
+ message: "All governing docs exist for project '#{slug}'"
848
+ )
849
+ else
850
+ checks << check(
851
+ category: "project_stores", name: "governing_docs_exist", status: "warn",
852
+ message: "#{missing_docs.size} governing doc(s) missing for project '#{slug}'",
853
+ details: missing_docs,
854
+ fixable: false
855
+ )
747
856
  end
748
857
  end
858
+ end
749
859
 
750
- # cross_references — if project has `parent` field, check global store intent tags
751
- parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
752
- next unless parent_id
860
+ # cross_references — if project has `parent` field, check global store intent tags
861
+ parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
862
+ return checks unless parent_id
753
863
 
754
- # Find the intent directory for the parent ID
755
- store_dir = File.join(plastic_home, "store")
756
- parent_dir = nil
757
- if File.directory?(store_dir)
758
- parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
759
- end
864
+ # Find the intent directory for the parent ID
865
+ store_dir = File.join(plastic_home, "store")
866
+ parent_dir = nil
867
+ if File.directory?(store_dir)
868
+ parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
869
+ end
760
870
 
761
- if parent_dir.nil?
762
- checks << check(
763
- category: "project_stores", name: "cross_references", status: "warn",
764
- message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
765
- fixable: false
766
- )
767
- next
768
- end
871
+ if parent_dir.nil?
872
+ checks << check(
873
+ category: "project_stores", name: "cross_references", status: "warn",
874
+ message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
875
+ fixable: false
876
+ )
877
+ return checks
878
+ end
769
879
 
770
- intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
771
- fm = parse_frontmatter(intent_md)
880
+ intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
881
+ fm = parse_frontmatter(intent_md)
772
882
 
773
- if fm.nil?
774
- checks << check(
775
- category: "project_stores", name: "cross_references", status: "warn",
776
- message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
777
- fixable: false
778
- )
779
- next
780
- end
883
+ if fm.nil?
884
+ checks << check(
885
+ category: "project_stores", name: "cross_references", status: "warn",
886
+ message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
887
+ fixable: false
888
+ )
889
+ return checks
890
+ end
781
891
 
782
- tags = fm["tags"]
783
- expected_tag = "project-#{slug}"
892
+ tags = fm["tags"]
893
+ expected_tag = "project-#{slug}"
784
894
 
785
- if tags.is_a?(Array) && tags.include?(expected_tag)
786
- checks << check(
787
- category: "project_stores", name: "cross_references", status: "pass",
788
- message: "Parent intent '#{parent_id}' has '#{expected_tag}' tag for project '#{slug}'"
789
- )
790
- else
791
- checks << check(
792
- category: "project_stores", name: "cross_references", status: "warn",
793
- message: "Parent intent '#{parent_id}' missing '#{expected_tag}' tag",
794
- details: ["Intent: store/#{parent_dir}", "Expected tag: #{expected_tag}", "Current tags: #{(tags || []).inspect}"],
795
- fixable: false
796
- )
797
- end
895
+ if tags.is_a?(Array) && tags.include?(expected_tag)
896
+ checks << check(
897
+ category: "project_stores", name: "cross_references", status: "pass",
898
+ message: "Parent intent '#{parent_id}' has '#{expected_tag}' tag for project '#{slug}'"
899
+ )
900
+ else
901
+ checks << check(
902
+ category: "project_stores", name: "cross_references", status: "warn",
903
+ message: "Parent intent '#{parent_id}' missing '#{expected_tag}' tag",
904
+ details: ["Intent: store/#{parent_dir}", "Expected tag: #{expected_tag}", "Current tags: #{(tags || []).inspect}"],
905
+ fixable: false
906
+ )
798
907
  end
799
908
 
800
909
  checks
@@ -898,6 +1007,48 @@ class Doctor
898
1007
  a_pre <=> b_pre
899
1008
  end
900
1009
 
1010
+ # --- Check category: QMD integration (read-only, optional) ---
1011
+ #
1012
+ # QMD is an optional integration. When `qmd` is not on PATH we emit a single
1013
+ # passing check and never fail — its absence is not a Plastic health problem.
1014
+ # When present, we report whether every Plastic store is registered as a QMD
1015
+ # collection. Everything here is read-only; we never invoke a mutating qmd
1016
+ # subcommand. detector/runner are injectable so tests stay hermetic.
1017
+ def check_qmd(detector: QmdSync.method(:detect), runner: QmdSync.default_runner)
1018
+ return [absent_qmd_check] unless detector.call
1019
+
1020
+ checks = [check(
1021
+ category: "qmd", name: "present", status: "pass",
1022
+ message: "QMD installed"
1023
+ )]
1024
+
1025
+ status = QmdSync.status(plastic_home: plastic_home, runner: runner, detector: detector)
1026
+ missing = status[:missing] || []
1027
+
1028
+ if status[:all_registered]
1029
+ checks << check(
1030
+ category: "qmd", name: "collections", status: "pass",
1031
+ message: "All #{status[:expected].size} Plastic store(s) registered as QMD collections"
1032
+ )
1033
+ else
1034
+ checks << check(
1035
+ category: "qmd", name: "collections", status: "warn",
1036
+ message: "#{missing.size} Plastic store(s) not registered as QMD collections",
1037
+ details: missing,
1038
+ fixable: true, fix_hint: "Run: qmd-sync register --all"
1039
+ )
1040
+ end
1041
+
1042
+ checks
1043
+ end
1044
+
1045
+ def absent_qmd_check
1046
+ check(
1047
+ category: "qmd", name: "present", status: "pass",
1048
+ message: "QMD not installed (optional integration)"
1049
+ )
1050
+ end
1051
+
901
1052
  # --- Run all checks ---
902
1053
 
903
1054
  def run_checks(agent_key)
@@ -908,28 +1059,72 @@ class Doctor
908
1059
  all_checks += check_core_files(agent_key)
909
1060
  all_checks += check_project_stores
910
1061
  all_checks += check_deprecations
1062
+ all_checks += check_qmd
911
1063
 
912
1064
  summarize(all_checks, agent_key)
913
1065
  end
914
1066
 
915
- # Fast runtime-liveness check: only the plumbing that proves Plastic can
916
- # operate (hooks, skills, scripts, core files). Skips the slow inventory
917
- # walks (global store refs, per-intent conventions, project stores,
918
- # deprecations) so it returns near-instantly. Used by `doctor.rb --core`.
1067
+ # Binary core sync check: agent registration + core files + manifest sync,
1068
+ # rolled up with binary: true so ANY warn or fail makes the overall status
1069
+ # "fail" (and "warn" is never emitted). Used by `doctor.rb --core`.
919
1070
  def run_core_checks(agent_key)
920
1071
  all_checks = []
921
1072
  all_checks += check_agent_registration(agent_key)
922
1073
  all_checks += check_core_files(agent_key)
1074
+ all_checks += check_manifest_sync(agent_key)
923
1075
 
924
- summarize(all_checks, agent_key)
1076
+ summarize(all_checks, agent_key, binary: true)
1077
+ end
1078
+
1079
+ # Store-scoped checks for `doctor.rb --store [global|<slug>]`.
1080
+ # :all -> global store + all project stores + all conventions
1081
+ # :global -> global store + conventions scoped to ["global"]
1082
+ # "<slug>" -> that project only + conventions scoped to ["project:<slug>"]
1083
+ # (fail if the slug is not registered in projects.yml)
1084
+ # 3-state roll-up (pass/warn/fail), like the full run.
1085
+ def run_store_checks(store)
1086
+ all_checks =
1087
+ case store
1088
+ when :all
1089
+ check_global_store + check_project_stores + check_conventions
1090
+ when :global
1091
+ check_global_store + check_conventions(scopes: ["global"])
1092
+ else
1093
+ all_checks_for_project_slug(store)
1094
+ end
1095
+
1096
+ summarize(all_checks, "claude", binary: false)
1097
+ end
1098
+
1099
+ # Build the checks for a single project slug, or a lone fail check when the
1100
+ # slug is unknown.
1101
+ def all_checks_for_project_slug(slug)
1102
+ projects_data = load_yaml_safe(File.join(plastic_home, "projects.yml"))
1103
+ projects = projects_data.is_a?(Hash) ? projects_data["projects"] : nil
1104
+
1105
+ unless projects.is_a?(Hash) && projects.key?(slug)
1106
+ return [check(
1107
+ category: "project_stores", name: "unknown_project", status: "fail",
1108
+ message: "unknown project '#{slug}'",
1109
+ fixable: false
1110
+ )]
1111
+ end
1112
+
1113
+ check_project_store(slug, projects[slug]) +
1114
+ check_conventions(scopes: ["project:#{slug}"])
925
1115
  end
926
1116
 
927
1117
  # Roll a list of checks up into the standard result envelope.
928
- def summarize(all_checks, agent_key)
1118
+ # When binary: true, the overall status is "pass" only if there are zero warn
1119
+ # AND zero fail; any warn or fail yields "fail" (never "warn"). When false
1120
+ # (the default) the classic 3-state pass/warn/fail roll-up is used.
1121
+ def summarize(all_checks, agent_key, binary: false)
929
1122
  summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
930
1123
  all_checks.each { |c| summary[c[:status].to_sym] += 1 }
931
1124
 
932
- overall = if summary[:fail] > 0
1125
+ overall = if binary
1126
+ (summary[:fail] > 0 || summary[:warn] > 0) ? "fail" : "pass"
1127
+ elsif summary[:fail] > 0
933
1128
  "fail"
934
1129
  elsif summary[:warn] > 0
935
1130
  "warn"
@@ -957,10 +1152,19 @@ class Doctor
957
1152
  exit 0
958
1153
  end
959
1154
 
960
- result = flags[:core] ? run_core_checks(flags[:agent]) : run_checks(flags[:agent])
1155
+ result =
1156
+ if !flags[:store].nil?
1157
+ run_store_checks(flags[:store])
1158
+ elsif flags[:core]
1159
+ run_core_checks(flags[:agent])
1160
+ else
1161
+ run_checks(flags[:agent])
1162
+ end
961
1163
 
962
1164
  puts JSON.pretty_generate(result)
963
1165
 
1166
+ # --core is binary: status is only ever pass|fail, so this maps to 0|2.
1167
+ # --store and the full run keep the 3-state 0/1/2 mapping.
964
1168
  case result[:status]
965
1169
  when "fail" then exit 2
966
1170
  when "warn" then exit 1
@@ -8,6 +8,7 @@ require "date"
8
8
  require "yaml"
9
9
  require_relative "lib/bridge"
10
10
  require_relative "lib/boot_banner"
11
+ require_relative "lib/qmd_sync"
11
12
  require_relative "doctor"
12
13
 
13
14
  index_path, store_root, mode, plugin_root = ARGV
@@ -204,6 +205,25 @@ parts = []
204
205
  parts << core_banner
205
206
  parts << ""
206
207
 
208
+ # --- QMD search status (intent 45a, READ-ONLY) ---
209
+ # Report-only line for the model (additionalContext), never systemMessage and
210
+ # never the exit code. QmdSync.status shells out to `qmd collection list`, so the
211
+ # whole block is guarded: a 2s timeout caps any hang, and rescue-all guarantees a
212
+ # slow/broken/missing qmd appends nothing and the hook continues cleanly.
213
+ begin
214
+ require "timeout"
215
+ qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: store_root) }
216
+ if qmd_status[:present]
217
+ if qmd_status[:all_registered]
218
+ parts << "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
219
+ else
220
+ parts << "QMD detected — run `qmd-sync register --all` to index your Plastic stores for search."
221
+ end
222
+ end
223
+ rescue Exception
224
+ # Any failure (timeout, missing binary, parse error) — stay silent, never crash.
225
+ end
226
+
207
227
  if plastic_md
208
228
  # Conventions always loaded first
209
229
  parts << plastic_md
@@ -13,24 +13,16 @@ module BootBanner
13
13
  # health: the Hash returned by Doctor#run_core_checks, or nil if the check
14
14
  # itself raised (degraded to an error banner).
15
15
  # version: the installed Plastic version string, or nil.
16
+ #
17
+ # Returns one of two binary lines:
18
+ # "Plastic Core loaded — v{VER} | doctor --core run: success"
19
+ # "Plastic Core loaded — v{VER} | doctor --core run: error — run /plastic-doctor"
16
20
  def render(health:, version:)
17
- return "Plastic Core: health check error — run /plastic-doctor" if health.nil?
18
-
19
- if health[:status] == "pass"
20
- "Plastic Core loaded — v#{version || "unknown"}"
21
+ ver = version || "unknown"
22
+ if !health.nil? && health[:status] == "pass"
23
+ "Plastic Core loaded — v#{ver} | doctor --core run: success"
21
24
  else
22
- bad = first_problem(health[:checks])
23
- if bad
24
- "Plastic Core loaded with issues — #{bad[:name]}: #{bad[:message]} — run /plastic-doctor"
25
- else
26
- "Plastic Core loaded with issues — run /plastic-doctor"
27
- end
25
+ "Plastic Core loaded — v#{ver} | doctor --core run: error — run /plastic-doctor"
28
26
  end
29
27
  end
30
-
31
- # First failing check, else first warning, else nil.
32
- def first_problem(checks)
33
- checks = checks || []
34
- checks.find { |c| c[:status] == "fail" } || checks.find { |c| c[:status] == "warn" }
35
- end
36
28
  end
@@ -177,6 +177,11 @@ class InstallerCore
177
177
 
178
178
  Dir.glob(File.join(plastic_home, "scripts", "*")).each { |f| FileUtils.chmod(0o755, f) if File.file?(f) }
179
179
 
180
+ global_files = core_files.values.map { |d| File.join(plastic_home, d) }
181
+ global_files << File.join(plastic_home, "VERSION")
182
+ global_files = global_files.select { |p| File.exist?(p) }
183
+ write_manifest(global_files, File.join(plastic_home, "manifest.json"))
184
+
180
185
  puts " \u{2705} Core files synced (v#{version})"
181
186
  end
182
187
 
@@ -199,6 +204,8 @@ class InstallerCore
199
204
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
200
205
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
201
206
  "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
207
+ "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
208
+ "scripts/qmd-sync" => "scripts/qmd-sync",
202
209
  "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
203
210
  "scripts/install.rb" => "scripts/install.rb",
204
211
  "scripts/update.rb" => "scripts/update.rb",
@@ -0,0 +1,160 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+
6
+ # QmdSync — the single place Plastic talks to QMD (intent 45a).
7
+ #
8
+ # Plastic computes only the Plastic-specific inputs (which store directory maps to
9
+ # which `plastic-`prefixed collection, derived from projects.yml, plus a short
10
+ # context description). The actual indexing is delegated to the `qmd` CLI; this
11
+ # module never reimplements QMD's logic. QMD is optional: every public entry
12
+ # no-ops cleanly when `qmd` is not on PATH.
13
+ #
14
+ # Pure and dependency-injected: all shelling-out goes through an injected
15
+ # `runner` callable, so the whole module is unit-testable with no real binary,
16
+ # no network, and no model downloads. The default runner shells out to `qmd`.
17
+ module QmdSync
18
+ module_function
19
+
20
+ CONTEXT_DESCRIPTION =
21
+ "Plastic intent store: intents, specs, plans, checklists, outcomes, and insights " \
22
+ "for the What/Why/How/Exec lifecycle. Search here for past decisions and work."
23
+
24
+ # A runner is `->(args_array) { [stdout_string, success_boolean] }`.
25
+ # The default invokes the real `qmd` binary.
26
+ def default_runner
27
+ lambda do |args|
28
+ require "open3"
29
+ out, _err, status = Open3.capture3("qmd", *args)
30
+ [out, status.success?]
31
+ end
32
+ end
33
+
34
+ # True when `qmd` is resolvable on PATH. The probe is injectable so tests do
35
+ # not depend on the host having qmd installed.
36
+ def detect(path_probe: method(:which_qmd))
37
+ !!path_probe.call
38
+ end
39
+
40
+ def which_qmd
41
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
42
+ candidate = File.join(dir, "qmd")
43
+ File.file?(candidate) && File.executable?(candidate)
44
+ end
45
+ end
46
+
47
+ # Collection name for a store directory.
48
+ # global store (<plastic_home>/store) -> "plastic-global"
49
+ # project store (<.../projects/<slug>/store) -> "plastic-<slug>"
50
+ # Slug is resolved from projects.yml by matching the project path; falls back
51
+ # to the directory's parent name when no registry match exists.
52
+ def collection_name(store_dir, plastic_home:)
53
+ store_dir = File.expand_path(store_dir)
54
+ global_store = File.expand_path(File.join(plastic_home, "store"))
55
+ return "plastic-global" if store_dir == global_store
56
+
57
+ slug = slug_for_store(store_dir, plastic_home: plastic_home)
58
+ "plastic-#{slug}"
59
+ end
60
+
61
+ # Every store Plastic knows about: the global store plus each registered
62
+ # project store. Returns [{collection:, dir:}, ...].
63
+ def enumerate_stores(plastic_home:)
64
+ stores = [{
65
+ collection: "plastic-global",
66
+ dir: File.expand_path(File.join(plastic_home, "store")),
67
+ }]
68
+
69
+ projects = load_projects(plastic_home)
70
+ projects.each do |slug, info|
71
+ path = info.is_a?(Hash) ? info["path"] : nil
72
+ next unless path
73
+ project_store = File.join(File.expand_path(path), "store")
74
+ # Project stores live under ~/.plastic/projects/<slug>/store as the mirror;
75
+ # registry `path` is the project code dir, so the tactical store is the
76
+ # plastic_home projects mirror.
77
+ mirror_store = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
78
+ dir = Dir.exist?(mirror_store) ? mirror_store : project_store
79
+ stores << { collection: "plastic-#{slug}", dir: dir }
80
+ end
81
+ stores
82
+ end
83
+
84
+ # Register a store directory as a collection and attach the Plastic context
85
+ # description. Idempotent: re-running is safe. No-op when qmd is absent.
86
+ def register(collection:, dir:, runner: default_runner, context: CONTEXT_DESCRIPTION, detector: method(:detect))
87
+ return skip_result unless detector.call
88
+ out1, ok1 = runner.call(["collection", "add", dir, "--name", collection])
89
+ _out2, ok2 = runner.call(["context", "add", collection, context])
90
+ { ran: true, ok: (ok1 && ok2), output: out1.to_s.strip }
91
+ end
92
+
93
+ # Re-index a single collection: refresh the corpus then its embeddings.
94
+ # Scoped embed (-c) keeps delivery-time reindex fast. No-op when qmd absent.
95
+ def reindex(collection:, runner: default_runner, detector: method(:detect))
96
+ return skip_result unless detector.call
97
+ _o1, ok1 = runner.call(["update"])
98
+ _o2, ok2 = runner.call(["embed", "-c", collection])
99
+ { ran: true, ok: (ok1 && ok2) }
100
+ end
101
+
102
+ # Read-only status used by doctor and the session-start report line.
103
+ # Returns a structured hash; never mutates the index.
104
+ def status(plastic_home:, runner: default_runner, detector: method(:detect))
105
+ return { present: false } unless detector.call
106
+
107
+ expected = enumerate_stores(plastic_home: plastic_home).map { |s| s[:collection] }
108
+ listed = list_collections(runner)
109
+ missing = expected - listed
110
+
111
+ { present: true, expected: expected, registered: listed,
112
+ missing: missing, all_registered: missing.empty? }
113
+ end
114
+
115
+ # --- internals ---
116
+
117
+ def skip_result
118
+ { ran: false, ok: true, skipped: true }
119
+ end
120
+
121
+ def list_collections(runner)
122
+ out, ok = runner.call(["collection", "list"])
123
+ return [] unless ok && out
124
+ # qmd prints lines like "plastic-global (qmd://plastic-global/)"; pull the
125
+ # leading collection token off each non-indented line.
126
+ out.lines.filter_map do |line|
127
+ next if line.start_with?(" ", "\t")
128
+ m = line.strip.match(/\A([A-Za-z0-9][\w.-]*)\b/)
129
+ m && m[1]
130
+ end.reject { |t| %w[Collections No].include?(t) }
131
+ end
132
+
133
+ def load_projects(plastic_home)
134
+ path = File.join(plastic_home, "projects.yml")
135
+ return {} unless File.exist?(path)
136
+ data = begin
137
+ YAML.safe_load(File.read(path)) || {}
138
+ rescue StandardError
139
+ {}
140
+ end
141
+ projects = data.is_a?(Hash) ? data["projects"] : nil
142
+ projects.is_a?(Hash) ? projects : {}
143
+ end
144
+
145
+ def slug_for_store(store_dir, plastic_home:)
146
+ projects = load_projects(plastic_home)
147
+ projects.each do |slug, info|
148
+ path = info.is_a?(Hash) ? info["path"] : nil
149
+ next unless path
150
+ project_root = File.expand_path(path)
151
+ mirror = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
152
+ return slug.to_s if store_dir == File.join(project_root, "store") || store_dir == mirror
153
+ end
154
+ # Fallback: <...>/projects/<slug>/store -> slug, else parent dir name.
155
+ parts = store_dir.split(File::SEPARATOR)
156
+ idx = parts.rindex("projects")
157
+ return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
158
+ File.basename(File.dirname(store_dir))
159
+ end
160
+ end
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # qmd-sync — deterministic CLI over QmdSync (intent 45a).
6
+ #
7
+ # Plastic skills and hooks call these verbs instead of assembling `qmd` commands
8
+ # themselves. QMD is optional: when `qmd` is absent every verb exits 0 and prints
9
+ # a skip notice, so callers can invoke unconditionally.
10
+ #
11
+ # Usage:
12
+ # qmd-sync detect # exit 0 if qmd present, 1 if absent
13
+ # qmd-sync register --store <dir> # register one store as a collection
14
+ # qmd-sync register --all # register the global store + all projects
15
+ # qmd-sync reindex --store <dir> # update + embed that store's collection
16
+ # qmd-sync status [--format json] # read-only status
17
+ #
18
+ # --home <path> overrides the Plastic home (default: ~/.plastic).
19
+
20
+ require "json"
21
+ require_relative "lib/qmd_sync"
22
+
23
+ def plastic_home(args)
24
+ if (i = args.index("--home")) && args[i + 1]
25
+ File.expand_path(args[i + 1])
26
+ else
27
+ File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
28
+ end
29
+ end
30
+
31
+ def opt(args, name)
32
+ (i = args.index(name)) && args[i + 1]
33
+ end
34
+
35
+ verb = ARGV.shift
36
+ home = plastic_home(ARGV)
37
+
38
+ unless QmdSync.detect
39
+ case verb
40
+ when "detect"
41
+ warn "QMD not detected on PATH."
42
+ exit 1
43
+ else
44
+ puts "QMD not detected; skipped (#{verb})."
45
+ exit 0
46
+ end
47
+ end
48
+
49
+ case verb
50
+ when "detect"
51
+ puts "QMD detected."
52
+ exit 0
53
+
54
+ when "register"
55
+ stores =
56
+ if ARGV.include?("--all")
57
+ QmdSync.enumerate_stores(plastic_home: home)
58
+ elsif (dir = opt(ARGV, "--store"))
59
+ [{ collection: QmdSync.collection_name(dir, plastic_home: home), dir: File.expand_path(dir) }]
60
+ else
61
+ warn "register: pass --store <dir> or --all"
62
+ exit 2
63
+ end
64
+ stores.each do |s|
65
+ next unless Dir.exist?(s[:dir])
66
+ res = QmdSync.register(collection: s[:collection], dir: s[:dir])
67
+ puts "registered #{s[:collection]} -> #{s[:dir]} (#{res[:ok] ? "ok" : "warn"})"
68
+ end
69
+ exit 0
70
+
71
+ when "reindex"
72
+ dir = opt(ARGV, "--store") or (warn("reindex: pass --store <dir>"); exit 2)
73
+ collection = QmdSync.collection_name(dir, plastic_home: home)
74
+ res = QmdSync.reindex(collection: collection)
75
+ puts "reindexed #{collection} (#{res[:ok] ? "ok" : "warn"})"
76
+ exit 0
77
+
78
+ when "status"
79
+ st = QmdSync.status(plastic_home: home)
80
+ if opt(ARGV, "--format") == "json"
81
+ puts JSON.generate(st)
82
+ else
83
+ puts "QMD present: #{st[:present]}"
84
+ puts "registered: #{Array(st[:registered]).join(", ")}"
85
+ puts "missing: #{Array(st[:missing]).join(", ")}" unless Array(st[:missing]).empty?
86
+ end
87
+ exit 0
88
+
89
+ else
90
+ warn "qmd-sync: unknown verb #{verb.inspect}. Use detect|register|reindex|status."
91
+ exit 2
92
+ end
package/scripts/update.rb CHANGED
@@ -13,6 +13,7 @@
13
13
  # `install --reinstall --ledger-action update` for the chosen version via npx.
14
14
 
15
15
  require_relative "lib/installer_core"
16
+ require_relative "doctor"
16
17
 
17
18
  class Update < InstallerCore
18
19
  PKG = "@zalom/plastic"
@@ -51,10 +52,31 @@ class Update < InstallerCore
51
52
  return 1
52
53
  end
53
54
  puts "\u{2b06}\u{fe0f} Updating Plastic #{iv} \u{2192} #{res[:target]}"
54
- perform_switch(res[:target], agent_args(argv))
55
+ exit_code = perform_switch(res[:target], agent_args(argv))
56
+ run_post_update_doctor if exit_code == 0
57
+ exit_code
55
58
  end
56
59
  end
57
60
 
61
+ # Run the full doctor after a successful update and print a human-readable
62
+ # summary. Informational only: does not raise and does not affect the update's
63
+ # exit code. Accepts injected `doctor` and `out` for hermetic unit tests.
64
+ def run_post_update_doctor(doctor: nil, out: $stdout)
65
+ doctor ||= Doctor.new
66
+ out.puts "\nRunning full doctor after update..."
67
+ result = doctor.run_checks("claude")
68
+ s = result[:summary]
69
+ out.puts " Doctor status: #{result[:status]} " \
70
+ "(pass: #{s[:pass]}, warn: #{s[:warn]}, fail: #{s[:fail]}, total: #{s[:total]})"
71
+ out.puts " Run /plastic-doctor for details." unless result[:status] == "pass"
72
+ result
73
+ rescue StandardError => e
74
+ # Non-blocking: a crash here (e.g. malformed file in the real store) must not
75
+ # undo or fail an update that already succeeded. Report and move on.
76
+ out.puts " doctor could not run: #{e.message} — run /plastic-doctor"
77
+ nil
78
+ end
79
+
58
80
  # Pure decision logic (hermetically testable). Returns a status hash.
59
81
  def compute_target(installed_version:, dist_tags:, requested_channel: nil)
60
82
  installed_ch = channel_for(installed_version)
@@ -161,11 +161,17 @@ During initial project creation, all decisions are non-destructive by definition
161
161
  - Update `chain` in the current intent's frontmatter
162
162
  6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
163
163
  7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
164
- 8. Disarm the lifecycle gate (auto delivery is finished):
164
+ 8. Refresh the QMD search index for this store (optional, no-op when QMD is absent):
165
+ ```bash
166
+ ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root>
167
+ ```
168
+ Delivery is the lifecycle event that keeps the search index fresh. `<store-root>` is the
169
+ store that holds this intent (the global store or the project store).
170
+ 9. Disarm the lifecycle gate (auto delivery is finished):
165
171
  ```bash
166
172
  ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_SESSION_ID"])'
167
173
  ```
168
- 9. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
174
+ 10. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
169
175
 
170
176
  ## Error Handling
171
177
 
@@ -40,6 +40,11 @@ here — run the data payload and fill + present the matching template:
40
40
  Fill the matching template from this skill's `templates/` and **present the filled Markdown
41
41
  in your reply** (every time). See `plastic-dashboard` for the fill rules and entry flow.
42
42
 
43
+ The board load runs the scoped store check on every load (`doctor --store <scope>`): the
44
+ global board runs `--store global` and a project board runs `--store <slug>`. The result
45
+ arrives in the payload as `store_health`; surface it as a one-line store-health note. It is
46
+ non-fatal (a warn or fail is shown as data, it does not block continuing).
47
+
43
48
  ### Then stop
44
49
  Present "here is the state, what next?" and wait. Offer active intents first, then future
45
50
  intents. Do not start executing work. The branches below are the only follow-ups:
@@ -157,7 +157,18 @@ cd ~/.plastic && git add . && git commit -m "feat: spawn project <slug> from int
157
157
  cd <project> && git add . && git commit -m "feat: initialize project from intent <ID>"
158
158
  ```
159
159
 
160
- ### 10. Announce
160
+ ### 10. Register the project store with QMD (optional)
161
+
162
+ If QMD is installed, register the new project's store as a search collection:
163
+
164
+ ```bash
165
+ ruby ~/.plastic/scripts/qmd-sync register --store ~/.plastic/projects/<slug>/store
166
+ ```
167
+
168
+ `qmd-sync` no-ops when QMD is absent, so run it unconditionally. This adds the
169
+ `plastic-<slug>` collection and indexes it.
170
+
171
+ ### 11. Announce
161
172
 
162
173
  Log in `## Insights` of each founding intent:
163
174
  > "Project `<slug>` created at `<path>`. Tactical mirror: `project-<slug>:1` (autonomous)"
@@ -32,10 +32,17 @@ ruby ~/.plastic/scripts/dashboard.rb [continue|project <slug>] --data
32
32
  - `continue` (default) → the **global** board payload (`mode: "global"`).
33
33
  - `project <slug>` → that **project** board payload (`mode: "project"`).
34
34
 
35
- The payload is read-only JSON. Global-board fields: `date`, `recently_worked`, `matrix`
36
- (`quick_win`/`next_big`/`defer`/`triage`/`research`, each a list of `{line, bullet, ...}`),
37
- `counts`, `projects`, `project_totals`. Project-board fields: `slug`, `description`,
38
- `recently_worked`, `matrix`, `counts`, `active`, `future`.
35
+ The payload is read-only JSON. Global-board fields: `date`, `store_health`, `recently_worked`,
36
+ `matrix` (`quick_win`/`next_big`/`defer`/`triage`/`research`, each a list of `{line, bullet, ...}`),
37
+ `counts`, `projects`, `project_totals`. Project-board fields: `slug`, `store_health`,
38
+ `description`, `recently_worked`, `matrix`, `counts`, `active`, `future`.
39
+
40
+ Each board load runs the scoped store check (`doctor --store <scope>`): the global board runs
41
+ `--store global` and a project board runs `--store <slug>`. The result rides in the payload as
42
+ `store_health` (`{scope, status, summary, failing_checks}`). Surface it as a one-line
43
+ store-health note on the board (for example `store health: pass (3/3)` or
44
+ `store health: warn (orphaned_intents)`). It is non-fatal: a warn or fail is shown as data and
45
+ never blocks the board.
39
46
 
40
47
  ### Step 2 — Fill the matching template
41
48
 
@@ -5,10 +5,51 @@ description: Use when diagnosing Plastic installation health, after updates, or
5
5
 
6
6
  # Doctor — Plastic Health Check
7
7
 
8
+ ## Scopes
9
+
10
+ Doctor has three scopes. Pick the right one for the situation:
11
+
12
+ | Scope | Flag | When it runs | States |
13
+ |-------|------|--------------|--------|
14
+ | Core check | `--core` | SessionStart hook (automatic), also available on demand | Binary: pass or error |
15
+ | Store check | `--store [global\|<slug>]` | Dashboard load, `plastic-continuing` | Three-state: pass / warn / fail |
16
+ | Full check | (no flag) | After every update (automatic), or `/plastic-doctor` | Three-state: pass / warn / fail |
17
+
18
+ ### `--core` (binary, manifest-backed)
19
+
20
+ Verifies that every core file is present and content-matches what the installed
21
+ version shipped. It checks two install manifests:
22
+
23
+ - `~/.plastic/manifest.json` (global manifest, covers PLASTIC.md and global scripts)
24
+ - `~/.claude/plastic/manifest.json` (agent-side manifest, covers agent scripts and hooks)
25
+
26
+ Each manifest maps a file path to its SHA256. The core check also confirms hooks
27
+ are registered, scripts are present and executable, and the installed version
28
+ matches. Result is binary: exit 0 on pass, non-zero on error. It never produces
29
+ warnings.
30
+
31
+ ### `--store [global|<slug>]`
32
+
33
+ Checks store state: intents are well-formed, INDEX sections are present, conventions
34
+ are followed, and links are valid. Scope options:
35
+
36
+ - No argument: checks all stores (global and all projects)
37
+ - `global`: checks only the global store
38
+ - A project slug (e.g. `--store plastic`): checks only that project's store
39
+
40
+ Produces three-state results (pass / warn / fail) and is run per-scope at dashboard
41
+ load time: the global board uses `--store global`, a project board uses `--store <slug>`.
42
+
43
+ ### Full doctor (no flag)
44
+
45
+ Runs core plus all store checks plus deprecation checks. This is what `/plastic-doctor`
46
+ invokes. It also runs automatically after every `plastic-update` (informational,
47
+ does not block or revert the update).
48
+
8
49
  ## When to Use
9
50
 
10
- - User invokes `/plastic-doctor`
11
- - After `plastic-update` completes (automatically)
51
+ - User invokes `/plastic-doctor` (full check)
52
+ - After `plastic-update` completes (automatically, full check)
12
53
  - When hooks aren't firing, skills aren't loading, or something seems broken
13
54
  - When the user says "check plastic", "diagnose", "what's wrong with plastic"
14
55
 
@@ -7,6 +7,10 @@
7
7
  2. Replace every {{placeholder}} below with the corresponding JSON value.
8
8
  3. For the category sections: the template shows ONE example section.
9
9
  Repeat that pattern for each unique category in the checks array.
10
+ The scope determines which categories appear:
11
+ --core scope: agent_registration, core_files (binary pass/error only)
12
+ --store scope: global_store, conventions, project_stores
13
+ full (no flag): all six categories below
10
14
  The six known categories and their display names are:
11
15
  global_store -> "Global Store"
12
16
  conventions -> "Conventions"
@@ -75,6 +75,7 @@ Capture observations in `## Insights`. When ALL checklist items are checked:
75
75
  3. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
76
76
  4. Update cluster entries to show `_(completed)_`
77
77
  5. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: complete intent <ID> — <name>"`
78
+ 6. Refresh the QMD search index for this store (optional, no-op when QMD is absent): `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root>`. Delivery is the lifecycle event that keeps the search index fresh.
78
79
 
79
80
  **This is NOT optional.** An intent with all checklist items done but no Outcome is a broken state. Complete the intent immediately — do not leave it for later.
80
81
 
@@ -100,6 +101,7 @@ Capture observations in `## Insights`. When ALL checklist items are checked:
100
101
  3. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
101
102
  4. Update cluster entries to show `_(completed)_`
102
103
  5. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: complete intent <ID> — <name>"`
104
+ 6. Refresh the QMD search index for this store (optional, no-op when QMD is absent): `ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root>`. Delivery is the lifecycle event that keeps the search index fresh.
103
105
 
104
106
  **This is NOT optional.** Complete the intent immediately when work is done.
105
107
 
@@ -153,7 +153,20 @@ Auto-commit the config change.
153
153
  Run `/plastic-doctor` and report the result. Resolve any fixable findings before
154
154
  announcing success.
155
155
 
156
- **Step 5: Announce**
156
+ **Step 5: Register stores with QMD (optional)**
157
+
158
+ QMD is an optional search layer. If it is installed, register the Plastic stores so
159
+ they are searchable:
160
+
161
+ ```bash
162
+ ruby ~/.plastic/scripts/qmd-sync detect && ruby ~/.plastic/scripts/qmd-sync register --all
163
+ ```
164
+
165
+ `qmd-sync` no-ops cleanly when QMD is absent, so this is safe to run unconditionally.
166
+ It registers `plastic-global` and every project store from `projects.yml`, then indexes
167
+ them. Report what was registered, or that QMD was not detected and the step was skipped.
168
+
169
+ **Step 6: Announce**
157
170
 
158
171
  > "Plastic installed globally at ~/.plastic/. Health check: [doctor summary].
159
172
  > Create your first intent with `/plastic-creating-intent`."