@zalom/plastic 1.0.0-alpha.24 → 1.0.0-alpha.26

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.24",
3
+ "version": "1.0.0-alpha.26",
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,7 @@ require "json"
13
13
  require "yaml"
14
14
  require "time"
15
15
  require "date"
16
+ require "digest"
16
17
 
17
18
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
18
19
  # run it hermetically (no eval, no global-constant rewriting).
@@ -63,6 +64,12 @@ class Doctor
63
64
  agent = "claude"
64
65
  help = false
65
66
  core = false
67
+ # store flag representation:
68
+ # nil — --store not given
69
+ # :all — --store with no value (check every store)
70
+ # :global — --store global
71
+ # "<slug>" — --store <slug> (a single project)
72
+ store = nil
66
73
 
67
74
  i = 0
68
75
  while i < argv.length
@@ -78,6 +85,15 @@ class Doctor
78
85
  when "--core"
79
86
  core = true
80
87
  i += 1
88
+ when "--store"
89
+ nxt = argv[i + 1]
90
+ if nxt && !nxt.start_with?("-")
91
+ store = (nxt == "global") ? :global : nxt
92
+ i += 2
93
+ else
94
+ store = :all
95
+ i += 1
96
+ end
81
97
  when "--help", "-h"
82
98
  help = true
83
99
  i += 1
@@ -86,7 +102,7 @@ class Doctor
86
102
  end
87
103
  end
88
104
 
89
- { agent: agent, help: help, core: core }
105
+ { agent: agent, help: help, core: core, store: store }
90
106
  end
91
107
 
92
108
  def show_help
@@ -99,8 +115,12 @@ class Doctor
99
115
 
100
116
  Options:
101
117
  --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.
118
+ --core Binary core sync check: verifies agent registration, core
119
+ files, and that every manifest-tracked file matches its
120
+ recorded SHA256. Exits 0 (pass) or 2 (fail); never warn.
121
+ --store [WHICH] Run only the store/conventions checks. WHICH may be:
122
+ global (global store only), a project slug (that project
123
+ only), or omitted (all stores). 3-state pass/warn/fail.
104
124
  -h, --help Show this help
105
125
 
106
126
  Output:
@@ -301,10 +321,14 @@ class Doctor
301
321
 
302
322
  # --- Check category 2: Conventions ---
303
323
 
304
- def check_conventions
324
+ # When `scopes` is a non-nil Array of scope strings (e.g. ["global"] or
325
+ # ["project:plastic"]), only intents whose :scope is in that list are checked.
326
+ # When nil (the default, used by the full run), every intent is checked.
327
+ def check_conventions(scopes: nil)
305
328
  checks = []
306
329
 
307
330
  intent_dirs = all_intent_dirs
331
+ intent_dirs = intent_dirs.select { |d| scopes.include?(d[:scope]) } unless scopes.nil?
308
332
  dirname_pattern = /^\w+--[\w-]+$/
309
333
 
310
334
  # intent_dirname
@@ -639,6 +663,84 @@ class Doctor
639
663
  checks
640
664
  end
641
665
 
666
+ # --- Check category: manifest sync (binary core integrity) ---
667
+
668
+ # Verify, for BOTH the global manifest and the agent-side manifest, that every
669
+ # file listed exists and its current SHA256 matches the recorded hash.
670
+ # - GLOBAL manifest: <plastic_home>/manifest.json
671
+ # - AGENT-side manifest: claude -> <dir>/plastic/manifest.json
672
+ # other -> <dir>/plastic-manifest.json
673
+ # Manifest format: { "version", "created", "files": { abs_path => sha256 } }.
674
+ # A missing manifest is a fail; any missing/mismatched listed file is a fail;
675
+ # otherwise a single pass per manifest.
676
+ def check_manifest_sync(agent_key)
677
+ checks = []
678
+
679
+ global_manifest = File.join(plastic_home, "manifest.json")
680
+ checks << verify_manifest(global_manifest, "global")
681
+
682
+ agent_dir = agents[agent_key][:dir]
683
+ agent_manifest = if agent_key == "claude"
684
+ File.join(agent_dir, "plastic", "manifest.json")
685
+ else
686
+ File.join(agent_dir, "plastic-manifest.json")
687
+ end
688
+ checks << verify_manifest(agent_manifest, "agent")
689
+
690
+ checks
691
+ end
692
+
693
+ # Check one manifest file. Returns a single check (pass or fail).
694
+ def verify_manifest(manifest_path, label)
695
+ unless File.exist?(manifest_path)
696
+ return check(
697
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
698
+ message: "#{label} core manifest missing — re-run the Plastic installer",
699
+ details: [tilde(manifest_path)],
700
+ fixable: true, fix_hint: "Re-run the Plastic installer"
701
+ )
702
+ end
703
+
704
+ data = read_json_safe(manifest_path)
705
+ files = data.is_a?(Hash) ? data["files"] : nil
706
+ unless files.is_a?(Hash)
707
+ return check(
708
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
709
+ message: "#{label} core manifest unreadable or malformed — re-run the Plastic installer",
710
+ details: [tilde(manifest_path)],
711
+ fixable: true, fix_hint: "Re-run the Plastic installer"
712
+ )
713
+ end
714
+
715
+ missing = []
716
+ mismatched = []
717
+ files.each do |path, recorded|
718
+ unless File.exist?(path)
719
+ missing << tilde(path)
720
+ next
721
+ end
722
+ actual = Digest::SHA256.file(path).hexdigest
723
+ mismatched << tilde(path) if actual != recorded
724
+ end
725
+
726
+ if missing.empty? && mismatched.empty?
727
+ check(
728
+ category: "manifest_sync", name: "#{label}_manifest", status: "pass",
729
+ message: "#{label} manifest: all #{files.size} tracked file(s) present and matching"
730
+ )
731
+ else
732
+ details = []
733
+ details += missing.map { |p| "missing: #{p}" }
734
+ details += mismatched.map { |p| "modified: #{p}" }
735
+ check(
736
+ category: "manifest_sync", name: "#{label}_manifest", status: "fail",
737
+ message: "#{label} manifest out of sync: #{missing.size} missing, #{mismatched.size} modified",
738
+ details: details,
739
+ fixable: true, fix_hint: "Re-run the Plastic installer to restore tracked files"
740
+ )
741
+ end
742
+ end
743
+
642
744
  # --- Check category 5: Project stores ---
643
745
 
644
746
  def check_project_stores
@@ -666,135 +768,140 @@ class Doctor
666
768
  return checks
667
769
  end
668
770
 
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
771
  projects.each do |slug, project_info|
674
- project_dir = File.join(plastic_home, "projects", slug)
772
+ checks += check_project_store(slug, project_info)
773
+ end
675
774
 
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
775
+ checks
776
+ end
690
777
 
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
778
+ # Per-project validation extracted from check_project_stores so a single
779
+ # project can be checked in isolation (used by `--store <slug>`).
780
+ def check_project_store(slug, project_info)
781
+ checks = []
782
+ project_dir = File.join(plastic_home, "projects", slug)
783
+
784
+ # project_dir_exists
785
+ if File.directory?(project_dir)
786
+ checks << check(
787
+ category: "project_stores", name: "project_dir_exists", status: "pass",
788
+ message: "Project directory exists for '#{slug}'"
789
+ )
790
+ else
791
+ checks << check(
792
+ category: "project_stores", name: "project_dir_exists", status: "warn",
793
+ message: "Project directory missing for '#{slug}'",
794
+ details: [tilde(project_dir)],
795
+ fixable: true, fix_hint: "Create the project store directory: mkdir -p #{tilde(project_dir)}"
796
+ )
797
+ end
706
798
 
707
- # project_yml_exists
708
- project_yml_path = File.join(plastic_home, "projects", slug, "project.yml")
709
- project_yml_data = nil
799
+ # project_index
800
+ project_index = File.join(project_dir, "INDEX.md")
801
+ if File.exist?(project_index)
802
+ checks << check(
803
+ category: "project_stores", name: "project_index", status: "pass",
804
+ message: "INDEX.md exists for project '#{slug}'"
805
+ )
806
+ else
807
+ checks << check(
808
+ category: "project_stores", name: "project_index", status: "warn",
809
+ message: "INDEX.md missing for project '#{slug}'",
810
+ details: [tilde(project_index)],
811
+ fixable: true, fix_hint: "Create INDEX.md in the project store directory"
812
+ )
813
+ end
710
814
 
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
815
+ # project_yml_exists
816
+ project_yml_path = File.join(plastic_home, "projects", slug, "project.yml")
817
+ project_yml_data = nil
724
818
 
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
819
+ if File.exist?(project_yml_path)
820
+ checks << check(
821
+ category: "project_stores", name: "project_yml_exists", status: "pass",
822
+ message: "project.yml exists for project '#{slug}'"
823
+ )
824
+ project_yml_data = load_yaml_safe(project_yml_path)
825
+ else
826
+ checks << check(
827
+ category: "project_stores", name: "project_yml_exists", status: "warn",
828
+ message: "project.yml missing for project '#{slug}'",
829
+ fixable: true, fix_hint: "Create project.yml from template — see plastic-creating-project"
830
+ )
831
+ end
832
+
833
+ # governing_docs_exist
834
+ if project_yml_data.is_a?(Hash) && project_yml_data["governing_docs"].is_a?(Array) && !project_yml_data["governing_docs"].empty?
835
+ project_path = project_info.is_a?(Hash) ? project_info["path"] : nil
836
+
837
+ if project_path
838
+ missing_docs = project_yml_data["governing_docs"].reject do |doc_path|
839
+ File.exist?(File.join(project_path, doc_path))
840
+ end
841
+
842
+ if missing_docs.empty?
843
+ checks << check(
844
+ category: "project_stores", name: "governing_docs_exist", status: "pass",
845
+ message: "All governing docs exist for project '#{slug}'"
846
+ )
847
+ else
848
+ checks << check(
849
+ category: "project_stores", name: "governing_docs_exist", status: "warn",
850
+ message: "#{missing_docs.size} governing doc(s) missing for project '#{slug}'",
851
+ details: missing_docs,
852
+ fixable: false
853
+ )
747
854
  end
748
855
  end
856
+ end
749
857
 
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
858
+ # cross_references — if project has `parent` field, check global store intent tags
859
+ parent_id = project_info.is_a?(Hash) ? project_info["parent"] : nil
860
+ return checks unless parent_id
753
861
 
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
862
+ # Find the intent directory for the parent ID
863
+ store_dir = File.join(plastic_home, "store")
864
+ parent_dir = nil
865
+ if File.directory?(store_dir)
866
+ parent_dir = Dir.children(store_dir).find { |d| d.start_with?("#{parent_id}--") }
867
+ end
760
868
 
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
869
+ if parent_dir.nil?
870
+ checks << check(
871
+ category: "project_stores", name: "cross_references", status: "warn",
872
+ message: "Parent intent '#{parent_id}' for project '#{slug}' not found in global store",
873
+ fixable: false
874
+ )
875
+ return checks
876
+ end
769
877
 
770
- intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
771
- fm = parse_frontmatter(intent_md)
878
+ intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
879
+ fm = parse_frontmatter(intent_md)
772
880
 
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
881
+ if fm.nil?
882
+ checks << check(
883
+ category: "project_stores", name: "cross_references", status: "warn",
884
+ message: "Cannot read frontmatter of parent intent '#{parent_id}' for project '#{slug}'",
885
+ fixable: false
886
+ )
887
+ return checks
888
+ end
781
889
 
782
- tags = fm["tags"]
783
- expected_tag = "project-#{slug}"
890
+ tags = fm["tags"]
891
+ expected_tag = "project-#{slug}"
784
892
 
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
893
+ if tags.is_a?(Array) && tags.include?(expected_tag)
894
+ checks << check(
895
+ category: "project_stores", name: "cross_references", status: "pass",
896
+ message: "Parent intent '#{parent_id}' has '#{expected_tag}' tag for project '#{slug}'"
897
+ )
898
+ else
899
+ checks << check(
900
+ category: "project_stores", name: "cross_references", status: "warn",
901
+ message: "Parent intent '#{parent_id}' missing '#{expected_tag}' tag",
902
+ details: ["Intent: store/#{parent_dir}", "Expected tag: #{expected_tag}", "Current tags: #{(tags || []).inspect}"],
903
+ fixable: false
904
+ )
798
905
  end
799
906
 
800
907
  checks
@@ -912,24 +1019,67 @@ class Doctor
912
1019
  summarize(all_checks, agent_key)
913
1020
  end
914
1021
 
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`.
1022
+ # Binary core sync check: agent registration + core files + manifest sync,
1023
+ # rolled up with binary: true so ANY warn or fail makes the overall status
1024
+ # "fail" (and "warn" is never emitted). Used by `doctor.rb --core`.
919
1025
  def run_core_checks(agent_key)
920
1026
  all_checks = []
921
1027
  all_checks += check_agent_registration(agent_key)
922
1028
  all_checks += check_core_files(agent_key)
1029
+ all_checks += check_manifest_sync(agent_key)
923
1030
 
924
- summarize(all_checks, agent_key)
1031
+ summarize(all_checks, agent_key, binary: true)
1032
+ end
1033
+
1034
+ # Store-scoped checks for `doctor.rb --store [global|<slug>]`.
1035
+ # :all -> global store + all project stores + all conventions
1036
+ # :global -> global store + conventions scoped to ["global"]
1037
+ # "<slug>" -> that project only + conventions scoped to ["project:<slug>"]
1038
+ # (fail if the slug is not registered in projects.yml)
1039
+ # 3-state roll-up (pass/warn/fail), like the full run.
1040
+ def run_store_checks(store)
1041
+ all_checks =
1042
+ case store
1043
+ when :all
1044
+ check_global_store + check_project_stores + check_conventions
1045
+ when :global
1046
+ check_global_store + check_conventions(scopes: ["global"])
1047
+ else
1048
+ all_checks_for_project_slug(store)
1049
+ end
1050
+
1051
+ summarize(all_checks, "claude", binary: false)
1052
+ end
1053
+
1054
+ # Build the checks for a single project slug, or a lone fail check when the
1055
+ # slug is unknown.
1056
+ def all_checks_for_project_slug(slug)
1057
+ projects_data = load_yaml_safe(File.join(plastic_home, "projects.yml"))
1058
+ projects = projects_data.is_a?(Hash) ? projects_data["projects"] : nil
1059
+
1060
+ unless projects.is_a?(Hash) && projects.key?(slug)
1061
+ return [check(
1062
+ category: "project_stores", name: "unknown_project", status: "fail",
1063
+ message: "unknown project '#{slug}'",
1064
+ fixable: false
1065
+ )]
1066
+ end
1067
+
1068
+ check_project_store(slug, projects[slug]) +
1069
+ check_conventions(scopes: ["project:#{slug}"])
925
1070
  end
926
1071
 
927
1072
  # Roll a list of checks up into the standard result envelope.
928
- def summarize(all_checks, agent_key)
1073
+ # When binary: true, the overall status is "pass" only if there are zero warn
1074
+ # AND zero fail; any warn or fail yields "fail" (never "warn"). When false
1075
+ # (the default) the classic 3-state pass/warn/fail roll-up is used.
1076
+ def summarize(all_checks, agent_key, binary: false)
929
1077
  summary = { pass: 0, warn: 0, fail: 0, total: all_checks.size }
930
1078
  all_checks.each { |c| summary[c[:status].to_sym] += 1 }
931
1079
 
932
- overall = if summary[:fail] > 0
1080
+ overall = if binary
1081
+ (summary[:fail] > 0 || summary[:warn] > 0) ? "fail" : "pass"
1082
+ elsif summary[:fail] > 0
933
1083
  "fail"
934
1084
  elsif summary[:warn] > 0
935
1085
  "warn"
@@ -957,10 +1107,19 @@ class Doctor
957
1107
  exit 0
958
1108
  end
959
1109
 
960
- result = flags[:core] ? run_core_checks(flags[:agent]) : run_checks(flags[:agent])
1110
+ result =
1111
+ if !flags[:store].nil?
1112
+ run_store_checks(flags[:store])
1113
+ elsif flags[:core]
1114
+ run_core_checks(flags[:agent])
1115
+ else
1116
+ run_checks(flags[:agent])
1117
+ end
961
1118
 
962
1119
  puts JSON.pretty_generate(result)
963
1120
 
1121
+ # --core is binary: status is only ever pass|fail, so this maps to 0|2.
1122
+ # --store and the full run keep the 3-state 0/1/2 mapping.
964
1123
  case result[:status]
965
1124
  when "fail" then exit 2
966
1125
  when "warn" then exit 1
@@ -284,6 +284,11 @@ payload = {
284
284
  "hookSpecificOutput" => {
285
285
  "hookEventName" => "SessionStart",
286
286
  "additionalContext" => parts.join("\n")
287
- }
287
+ },
288
+ # Intent 54: additionalContext is model-only, so the banner stays invisible to
289
+ # the human. The top-level systemMessage channel is rendered in the user's
290
+ # terminal (and re-fires on /clear). Reuse the same BootBanner line so the
291
+ # visible banner and the model-facing banner cannot drift.
292
+ "systemMessage" => core_banner
288
293
  }
289
294
  puts JSON.generate(payload)
@@ -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
 
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)
@@ -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:
@@ -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"