@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 +1 -1
- package/README.md +5 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +39 -1
- package/scripts/doctor.rb +285 -126
- package/scripts/hook-session-start +6 -1
- package/scripts/lib/boot_banner.rb +8 -16
- package/scripts/lib/installer_core.rb +5 -0
- package/scripts/update.rb +23 -1
- package/skills/continuing/SKILL.md +5 -0
- package/skills/dashboard/SKILL.md +11 -4
- package/skills/doctor/SKILL.md +43 -2
- package/skills/doctor/report.md +4 -0
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
package/scripts/dashboard.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
772
|
+
checks += check_project_store(slug, project_info)
|
|
773
|
+
end
|
|
675
774
|
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
771
|
-
|
|
878
|
+
intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
|
|
879
|
+
fm = parse_frontmatter(intent_md)
|
|
772
880
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
783
|
-
|
|
890
|
+
tags = fm["tags"]
|
|
891
|
+
expected_tag = "project-#{slug}"
|
|
784
892
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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`, `
|
|
36
|
-
(`quick_win`/`next_big`/`defer`/`triage`/`research`, each a list of `{line, bullet, ...}`),
|
|
37
|
-
`counts`, `projects`, `project_totals`. Project-board fields: `slug`, `
|
|
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
|
|
package/skills/doctor/SKILL.md
CHANGED
|
@@ -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
|
|
package/skills/doctor/report.md
CHANGED
|
@@ -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"
|