@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 +1 -1
- package/README.md +5 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +39 -1
- package/scripts/doctor.rb +330 -126
- package/scripts/hook-session-start +20 -0
- package/scripts/lib/boot_banner.rb +8 -16
- package/scripts/lib/installer_core.rb +7 -0
- package/scripts/lib/qmd_sync.rb +160 -0
- package/scripts/qmd-sync +92 -0
- package/scripts/update.rb +23 -1
- package/skills/auto/SKILL.md +8 -2
- package/skills/continuing/SKILL.md +5 -0
- package/skills/creating-project/SKILL.md +12 -1
- package/skills/dashboard/SKILL.md +11 -4
- package/skills/doctor/SKILL.md +43 -2
- package/skills/doctor/report.md +4 -0
- package/skills/executing-plan/SKILL.md +2 -0
- package/skills/install/SKILL.md +14 -1
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,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
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
774
|
+
checks += check_project_store(slug, project_info)
|
|
775
|
+
end
|
|
675
776
|
|
|
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
|
|
777
|
+
checks
|
|
778
|
+
end
|
|
690
779
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
)
|
|
723
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
771
|
-
|
|
880
|
+
intent_md = File.join(store_dir, parent_dir, "#{parent_dir}.md")
|
|
881
|
+
fm = parse_frontmatter(intent_md)
|
|
772
882
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
783
|
-
|
|
892
|
+
tags = fm["tags"]
|
|
893
|
+
expected_tag = "project-#{slug}"
|
|
784
894
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
package/scripts/qmd-sync
ADDED
|
@@ -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)
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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`, `
|
|
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"
|
|
@@ -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
|
|
package/skills/install/SKILL.md
CHANGED
|
@@ -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:
|
|
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`."
|