@zalom/plastic 1.0.0-alpha.3 → 1.0.0-alpha.31

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.
Files changed (87) hide show
  1. package/PLASTIC.md +134 -469
  2. package/README.md +95 -58
  3. package/agents/future-intent-researcher.md +1 -1
  4. package/agents/intent-curator.md +1 -1
  5. package/bin/plastic.js +57 -0
  6. package/bin/test +28 -0
  7. package/deprecations.yml +1 -10
  8. package/hooks/auto-arm +5 -0
  9. package/hooks/bash-gate +3 -0
  10. package/hooks/check-update +12 -8
  11. package/hooks/code-gate +12 -0
  12. package/hooks/gate-check +3 -1
  13. package/hooks/hooks.json +25 -4
  14. package/hooks/statusline +50 -10
  15. package/package.json +2 -2
  16. package/scripts/dashboard.rb +680 -0
  17. package/scripts/doctor.rb +1227 -0
  18. package/scripts/hook-auto-arm +51 -0
  19. package/scripts/hook-bash-gate +41 -0
  20. package/scripts/hook-code-gate +27 -0
  21. package/scripts/hook-continue +15 -114
  22. package/scripts/hook-gate-check +30 -34
  23. package/scripts/hook-session-start +121 -31
  24. package/scripts/install.rb +91 -487
  25. package/scripts/lib/boot_banner.rb +28 -0
  26. package/scripts/lib/bridge.rb +358 -10
  27. package/scripts/lib/installer_core.rb +747 -0
  28. package/scripts/lib/intent_validator.rb +90 -0
  29. package/scripts/lib/qmd_sync.rb +159 -0
  30. package/scripts/lib/store_provisioning.rb +100 -0
  31. package/scripts/migrate-to-global +1 -1
  32. package/scripts/provision-project-store +53 -0
  33. package/scripts/qmd-sync +92 -0
  34. package/scripts/select-update-target +93 -0
  35. package/scripts/uninstall.rb +53 -0
  36. package/scripts/update.rb +164 -0
  37. package/scripts/validate-intent +54 -0
  38. package/scripts/versions.rb +141 -0
  39. package/skills/_active-intent-gate.md +26 -0
  40. package/skills/add-project-store/SKILL.md +54 -0
  41. package/skills/auto/SKILL.md +69 -9
  42. package/skills/auto/evals/evals.json +92 -0
  43. package/skills/auto/references/agent-architecture.md +60 -0
  44. package/skills/brainstorming/SKILL.md +143 -0
  45. package/skills/brainstorming-grill-me/SKILL.md +5 -5
  46. package/skills/continuing/SKILL.md +92 -82
  47. package/skills/continuing/evals/evals.json +136 -0
  48. package/skills/continuing/references/context-management.md +32 -0
  49. package/skills/creating-intent/SKILL.md +38 -9
  50. package/skills/creating-intent/references/lifecycle.md +74 -0
  51. package/skills/creating-intent/references/wikilinks.md +8 -0
  52. package/skills/creating-project/SKILL.md +40 -8
  53. package/skills/creating-project/references/hubs-projects.md +55 -0
  54. package/skills/dashboard/SKILL.md +121 -0
  55. package/skills/dashboard/templates/dashboard-global.md +31 -0
  56. package/skills/dashboard/templates/dashboard-project.md +40 -0
  57. package/skills/doctor/SKILL.md +159 -0
  58. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  59. package/skills/doctor/report.md +100 -0
  60. package/skills/evaluating-skills/SKILL.md +140 -0
  61. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  62. package/skills/evaluating-skills/evals/evals.json +75 -0
  63. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  64. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  65. package/skills/executing-plan/SKILL.md +5 -3
  66. package/skills/install/SKILL.md +69 -8
  67. package/skills/intent-curator/SKILL.md +3 -3
  68. package/skills/linking-intents/SKILL.md +5 -1
  69. package/skills/linking-intents/references/zettelkasten.md +33 -0
  70. package/skills/managing-index/SKILL.md +5 -1
  71. package/skills/releasing/SKILL.md +119 -18
  72. package/skills/releasing/references/deprecations.md +60 -0
  73. package/skills/research/SKILL.md +114 -0
  74. package/skills/savepoint/SKILL.md +46 -37
  75. package/skills/savepoint/references/context-management.md +32 -0
  76. package/skills/uninstall/SKILL.md +39 -28
  77. package/skills/update/SKILL.md +41 -36
  78. package/skills/versions/SKILL.md +65 -0
  79. package/skills/writing-instructions/SKILL.md +159 -0
  80. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  81. package/skills/writing-plans/SKILL.md +183 -0
  82. package/templates/agents.md +16 -0
  83. package/templates/outcome.md +13 -0
  84. package/templates/project.yml +5 -0
  85. package/templates/savepoint.md +14 -13
  86. package/templates/spec.md +25 -0
  87. package/bin/install.js +0 -29
@@ -0,0 +1,90 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "date"
6
+ require "time"
7
+
8
+ # IntentValidator — the single source of truth for "is an intent born complete?"
9
+ # (intent 60).
10
+ #
11
+ # An intent is born complete when its frontmatter carries every required field
12
+ # and its `sources` and `chain` are well-formed arrays of Folgezettel id references
13
+ # (bare ids like `1a2`, or cross-store refs like `global:1a2`; integer ids are coerced).
14
+ # This module is the only definition of that contract; the `validate-intent` CLI,
15
+ # the doctor diagnostics, and the creating-intent skill all consult it so the
16
+ # definition never drifts across copies.
17
+ #
18
+ # Pure and dependency-injected: `validate` accepts an injectable `plastic_home`
19
+ # for house-style parity, parses with a rescue-to-safe-default reader, uses no
20
+ # `eval`, and performs no file writes and no global-constant injection.
21
+ module IntentValidator
22
+ module_function
23
+
24
+ # Must match Doctor::REQUIRED_FRONTMATTER_FIELDS (scripts/doctor.rb).
25
+ REQUIRED_FIELDS = %w[id intent sources chain created author tags].freeze
26
+
27
+ # Fields whose value must be a well-formed array of valid id strings.
28
+ ARRAY_ID_FIELDS = %w[sources chain].freeze
29
+
30
+ # Folgezettel id form: digits then an optional lowercase-letter/digit suffix
31
+ # (for example "14", "14a", "4a1"). Mirrors scripts/folgezettel-id.
32
+ ID_PATTERN = /\A([a-z0-9-]+:)?\d+[a-z0-9]*\z/
33
+
34
+ # True iff `value` is a String matching the Folgezettel id form.
35
+ def valid_id?(value)
36
+ value.to_s.match?(ID_PATTERN)
37
+ end
38
+
39
+ # Read a file's YAML frontmatter, returning the parsed Hash (or {} when the
40
+ # frontmatter block is empty), or nil when there is no parseable frontmatter.
41
+ # A copy of Doctor#parse_frontmatter so `created:` dates do not crash.
42
+ def parse_frontmatter(path)
43
+ return nil unless File.exist?(path)
44
+
45
+ content = File.read(path)
46
+ return nil unless content.start_with?("---")
47
+
48
+ parts = content.split("---", 3)
49
+ return nil if parts.length < 3
50
+
51
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
52
+ rescue StandardError
53
+ nil
54
+ end
55
+
56
+ # PURE: given a parsed frontmatter Hash (or nil), return
57
+ # { ok: Boolean, missing: [field names], errors: [human strings] }.
58
+ def validate_frontmatter(fm)
59
+ unless fm.is_a?(Hash)
60
+ return { ok: false, missing: REQUIRED_FIELDS.dup, errors: ["no frontmatter found"] }
61
+ end
62
+
63
+ missing = REQUIRED_FIELDS.reject { |f| fm.key?(f) }
64
+ errors = missing.map { |f| "missing required field: #{f}" }
65
+
66
+ ARRAY_ID_FIELDS.each do |key|
67
+ next unless fm.key?(key)
68
+
69
+ value = fm[key]
70
+ unless value.is_a?(Array)
71
+ errors << "#{key} must be an array"
72
+ next
73
+ end
74
+
75
+ value.each do |element|
76
+ errors << "#{key} has invalid id: #{element.inspect}" unless valid_id?(element)
77
+ end
78
+ end
79
+
80
+ { ok: missing.empty? && errors.empty?, missing: missing, errors: errors }
81
+ end
82
+
83
+ # Resolve an intent directory's primary md file and validate its frontmatter.
84
+ # `plastic_home` is accepted for house-style parity (injectable) even though
85
+ # validation reads the intent dir directly.
86
+ def validate(intent_dir, plastic_home: File.join(Dir.home, ".plastic"))
87
+ md_path = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
88
+ validate_frontmatter(parse_frontmatter(md_path))
89
+ end
90
+ end
@@ -0,0 +1,159 @@
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
+ # A runner is `->(args_array) { [stdout_string, success_boolean] }`.
21
+ # The default invokes the real `qmd` binary.
22
+ def default_runner
23
+ lambda do |args|
24
+ require "open3"
25
+ out, _err, status = Open3.capture3("qmd", *args)
26
+ [out, status.success?]
27
+ end
28
+ end
29
+
30
+ # True when `qmd` is resolvable on PATH. The probe is injectable so tests do
31
+ # not depend on the host having qmd installed.
32
+ def detect(path_probe: method(:which_qmd))
33
+ !!path_probe.call
34
+ end
35
+
36
+ def which_qmd
37
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
38
+ candidate = File.join(dir, "qmd")
39
+ File.file?(candidate) && File.executable?(candidate)
40
+ end
41
+ end
42
+
43
+ # Collection name for a store directory.
44
+ # global store (<plastic_home>/store) -> "plastic-global"
45
+ # project store (<.../projects/<slug>/store) -> "plastic-<slug>"
46
+ # Slug is resolved from projects.yml by matching the project path; falls back
47
+ # to the directory's parent name when no registry match exists.
48
+ def collection_name(store_dir, plastic_home:)
49
+ store_dir = File.expand_path(store_dir)
50
+ global_store = File.expand_path(File.join(plastic_home, "store"))
51
+ return "plastic-global" if store_dir == global_store
52
+
53
+ slug = slug_for_store(store_dir, plastic_home: plastic_home)
54
+ "plastic-#{slug}"
55
+ end
56
+
57
+ # Every store Plastic knows about: the global store plus each registered
58
+ # project store. Returns [{collection:, dir:}, ...].
59
+ def enumerate_stores(plastic_home:)
60
+ stores = [{
61
+ collection: "plastic-global",
62
+ dir: File.expand_path(File.join(plastic_home, "store")),
63
+ }]
64
+
65
+ projects = load_projects(plastic_home)
66
+ projects.each do |slug, info|
67
+ path = info.is_a?(Hash) ? info["path"] : nil
68
+ next unless path
69
+ project_store = File.join(File.expand_path(path), "store")
70
+ # Project stores live under ~/.plastic/projects/<slug>/store as the mirror;
71
+ # registry `path` is the project code dir, so the tactical store is the
72
+ # plastic_home projects mirror.
73
+ mirror_store = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
74
+ dir = Dir.exist?(mirror_store) ? mirror_store : project_store
75
+ stores << { collection: "plastic-#{slug}", dir: dir }
76
+ end
77
+ stores
78
+ end
79
+
80
+ # Register a store directory as a collection. Idempotent: re-running is safe.
81
+ # `qmd collection add` exits non-zero when the collection already exists, so we
82
+ # check the collection list first and treat an existing collection as success.
83
+ # No-op when qmd is absent. (`qmd context add` attaches a summary to a document
84
+ # path, not a collection-level description, so it is not used here.)
85
+ def register(collection:, dir:, runner: default_runner, detector: method(:detect))
86
+ return skip_result unless detector.call
87
+ return { ran: true, ok: true, output: "exists" } if list_collections(runner).include?(collection)
88
+ out, ok = runner.call(["collection", "add", dir, "--name", collection])
89
+ { ran: true, ok: ok, output: out.to_s.strip }
90
+ end
91
+
92
+ # Re-index a single collection: refresh the corpus then its embeddings.
93
+ # Scoped embed (-c) keeps delivery-time reindex fast. No-op when qmd absent.
94
+ def reindex(collection:, runner: default_runner, detector: method(:detect))
95
+ return skip_result unless detector.call
96
+ _o1, ok1 = runner.call(["update"])
97
+ _o2, ok2 = runner.call(["embed", "-c", collection])
98
+ { ran: true, ok: (ok1 && ok2) }
99
+ end
100
+
101
+ # Read-only status used by doctor and the session-start report line.
102
+ # Returns a structured hash; never mutates the index.
103
+ def status(plastic_home:, runner: default_runner, detector: method(:detect))
104
+ return { present: false } unless detector.call
105
+
106
+ expected = enumerate_stores(plastic_home: plastic_home).map { |s| s[:collection] }
107
+ listed = list_collections(runner)
108
+ missing = expected - listed
109
+
110
+ { present: true, expected: expected, registered: listed,
111
+ missing: missing, all_registered: missing.empty? }
112
+ end
113
+
114
+ # --- internals ---
115
+
116
+ def skip_result
117
+ { ran: false, ok: true, skipped: true }
118
+ end
119
+
120
+ def list_collections(runner)
121
+ out, ok = runner.call(["collection", "list"])
122
+ return [] unless ok && out
123
+ # qmd prints lines like "plastic-global (qmd://plastic-global/)"; pull the
124
+ # leading collection token off each non-indented line.
125
+ out.lines.filter_map do |line|
126
+ next if line.start_with?(" ", "\t")
127
+ m = line.strip.match(/\A([A-Za-z0-9][\w.-]*)\b/)
128
+ m && m[1]
129
+ end.reject { |t| %w[Collections No].include?(t) }
130
+ end
131
+
132
+ def load_projects(plastic_home)
133
+ path = File.join(plastic_home, "projects.yml")
134
+ return {} unless File.exist?(path)
135
+ data = begin
136
+ YAML.safe_load(File.read(path)) || {}
137
+ rescue StandardError
138
+ {}
139
+ end
140
+ projects = data.is_a?(Hash) ? data["projects"] : nil
141
+ projects.is_a?(Hash) ? projects : {}
142
+ end
143
+
144
+ def slug_for_store(store_dir, plastic_home:)
145
+ projects = load_projects(plastic_home)
146
+ projects.each do |slug, info|
147
+ path = info.is_a?(Hash) ? info["path"] : nil
148
+ next unless path
149
+ project_root = File.expand_path(path)
150
+ mirror = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
151
+ return slug.to_s if store_dir == File.join(project_root, "store") || store_dir == mirror
152
+ end
153
+ # Fallback: <...>/projects/<slug>/store -> slug, else parent dir name.
154
+ parts = store_dir.split(File::SEPARATOR)
155
+ idx = parts.rindex("projects")
156
+ return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
157
+ File.basename(File.dirname(store_dir))
158
+ end
159
+ end
@@ -0,0 +1,100 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ # StoreProvisioning — the single source of truth for creating a registered
8
+ # project's intent store (intent 61).
9
+ #
10
+ # A project can be registered in `projects.yml` yet have no store on disk. This
11
+ # module deterministically and idempotently provisions that store at
12
+ # `~/.plastic/projects/{slug}/store`: it makes the store directory, then writes,
13
+ # only if missing, `.gitkeep`, `INDEX.md` (from templates/index.md), and
14
+ # `project.yml` (from templates/project.yml). Re-running never clobbers existing
15
+ # files. The logic was migrated here from the orphaned
16
+ # `InstallerCore#bootstrap_project_store` so there is one definition that the
17
+ # `provision-project-store` CLI, the `plastic-add-project-store` skill, the
18
+ # doctor fix hint, and the project skills all consult.
19
+ #
20
+ # Pure filesystem and dependency-injected: `provision` accepts an injectable
21
+ # `plastic_home` and `package_root` (so templates resolve from a known root and
22
+ # tests stay hermetic), uses no `eval`, performs no qmd mutation, no `system`
23
+ # or `spawn`, no network, and no global-constant injection. An unknown or
24
+ # unregistered slug creates nothing and returns an error result.
25
+ module StoreProvisioning
26
+ module_function
27
+
28
+ # Repo root resolved from this file's location (scripts/lib/), so templates
29
+ # resolve at package_root/templates/...; tests can inject a fake package_root.
30
+ PACKAGE_ROOT = File.expand_path("../..", __dir__)
31
+
32
+ # Provision the store for a registered project.
33
+ #
34
+ # Returns a result hash mirroring IntentValidator.validate's hash style:
35
+ # { ok: true, store_dir: "...", created: [paths written this run] }
36
+ # { ok: false, error: "..." } (nothing is created)
37
+ def provision(slug, plastic_home: File.join(Dir.home, ".plastic"),
38
+ package_root: PACKAGE_ROOT)
39
+ unless registered?(slug, plastic_home)
40
+ return {
41
+ ok: false,
42
+ error: "project '#{slug}' is not registered in projects.yml; " \
43
+ "provisioning requires the project to already be registered",
44
+ }
45
+ end
46
+
47
+ project_dir = File.join(plastic_home, "projects", slug)
48
+ store_dir = File.join(project_dir, "store")
49
+ FileUtils.mkdir_p(store_dir)
50
+
51
+ created = []
52
+ created << write_if_missing(File.join(store_dir, ".gitkeep"), "")
53
+
54
+ index_template = File.join(package_root, "templates", "index.md")
55
+ if File.exist?(index_template)
56
+ created << write_if_missing(File.join(project_dir, "INDEX.md"),
57
+ File.read(index_template))
58
+ end
59
+
60
+ project_template = File.join(package_root, "templates", "project.yml")
61
+ if File.exist?(project_template)
62
+ created << write_if_missing(File.join(project_dir, "project.yml"),
63
+ File.read(project_template))
64
+ end
65
+
66
+ { ok: true, store_dir: store_dir, created: created.compact }
67
+ end
68
+
69
+ # True iff `slug` is a key under the `projects` mapping in projects.yml.
70
+ # Reads with a rescue-to-safe-default reader so a malformed file never raises.
71
+ def registered?(slug, plastic_home)
72
+ projects = load_projects(plastic_home)
73
+ projects.is_a?(Hash) && projects.key?(slug)
74
+ end
75
+
76
+ # Parse projects.yml -> the `projects` Hash, or {} on any error/absence.
77
+ # Mirrors QmdSync#load_projects (scripts/lib/qmd_sync.rb).
78
+ def load_projects(plastic_home)
79
+ path = File.join(plastic_home, "projects.yml")
80
+ return {} unless File.exist?(path)
81
+
82
+ data = begin
83
+ YAML.safe_load(File.read(path)) || {}
84
+ rescue StandardError
85
+ {}
86
+ end
87
+ projects = data.is_a?(Hash) ? data["projects"] : nil
88
+ projects.is_a?(Hash) ? projects : {}
89
+ end
90
+
91
+ # Write `content` to `path` only when `path` does not exist. Returns the path
92
+ # when a file was written, or nil when the file already existed (so callers
93
+ # can build a `created` list). Copied from InstallerCore#write_if_missing.
94
+ def write_if_missing(path, content)
95
+ return nil if File.exist?(path)
96
+
97
+ File.write(path, content)
98
+ path
99
+ end
100
+ end
@@ -17,7 +17,7 @@ GLOBAL_ROOT="$HOME/.plastic"
17
17
  LOCAL_PLASTIC="$PROJECT_PATH/.plastic"
18
18
 
19
19
  if [ ! -d "$GLOBAL_ROOT" ]; then
20
- echo "Error: Global Plastic not installed. Run /plastic:install first." >&2
20
+ echo "Error: Global Plastic not installed. Run /plastic-install first." >&2
21
21
  exit 1
22
22
  fi
23
23
 
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # provision-project-store — deterministic CLI over StoreProvisioning (intent 61).
6
+ #
7
+ # Creates a registered project's store at ~/.plastic/projects/{slug}/store: it
8
+ # makes the store directory, then writes, only if missing, .gitkeep, INDEX.md,
9
+ # and project.yml. It is pure filesystem and idempotent, so re-running never
10
+ # clobbers existing files and performs no qmd mutation. The project must already
11
+ # be registered in projects.yml; an unregistered slug exits non-zero and creates
12
+ # nothing.
13
+ #
14
+ # Usage:
15
+ # provision-project-store <slug> [--home <path>]
16
+ #
17
+ # Exit codes: 0 (store provisioned), 1 (unregistered slug / error on stderr),
18
+ # 2 (usage). --home <path> overrides the Plastic home (default: ~/.plastic).
19
+
20
+ require_relative "lib/store_provisioning"
21
+
22
+ def plastic_home(args)
23
+ if (i = args.index("--home")) && args[i + 1]
24
+ File.expand_path(args[i + 1])
25
+ else
26
+ File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
27
+ end
28
+ end
29
+
30
+ home = plastic_home(ARGV)
31
+
32
+ # First positional arg, skipping any flag and the value that follows --home.
33
+ home_value_index = (i = ARGV.index("--home")) ? i + 1 : nil
34
+ slug_index = ARGV.each_index.find do |idx|
35
+ arg = ARGV[idx]
36
+ !arg.start_with?("--") && idx != home_value_index
37
+ end
38
+ slug = slug_index && ARGV[slug_index]
39
+
40
+ if slug.nil?
41
+ warn "usage: provision-project-store <slug> [--home <path>]"
42
+ exit 2
43
+ end
44
+
45
+ result = StoreProvisioning.provision(slug, plastic_home: home)
46
+
47
+ if result[:ok]
48
+ puts "OK: #{result[:store_dir]}"
49
+ exit 0
50
+ end
51
+
52
+ warn result[:error]
53
+ exit 1
@@ -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
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env ruby
2
+ # plastic-script-version: 1.0.0
3
+ # Channel-aware update target selector.
4
+ #
5
+ # Usage: npm view @zalom/plastic dist-tags --json | select-update-target <current-version>
6
+ #
7
+ # Prints the best update target to stdout, or nothing if already up to date.
8
+ #
9
+ # Rule: target = the highest version, among the dist-tags reachable from the
10
+ # installed channel, that is strictly greater than the installed version (semver).
11
+ # alpha installed -> reachable: alpha, beta, latest
12
+ # beta installed -> reachable: beta, latest (never downgrade to alpha)
13
+ # stable installed -> reachable: latest (stable only)
14
+ # A prerelease (1.1.0-alpha.1) is LOWER than its release (1.1.0); the `latest`
15
+ # placeholder (0.0.1) is naturally excluded as lower than any 1.0.0-alpha.N.
16
+
17
+ require "json"
18
+
19
+ # Parse "MAJOR.MINOR.PATCH[-prerelease]" into a comparable structure.
20
+ # Returns nil for anything that isn't a clean semver string.
21
+ def parse(version)
22
+ m = /\A(\d+)\.(\d+)\.(\d+)(?:-(.+))?\z/.match(version.to_s.strip)
23
+ return nil unless m
24
+ { maj: m[1].to_i, min: m[2].to_i, pat: m[3].to_i,
25
+ pre: m[4] ? m[4].split(".") : nil }
26
+ end
27
+
28
+ # Semver precedence (§11). Returns -1, 0, or 1.
29
+ def compare(a, b)
30
+ %i[maj min pat].each do |k|
31
+ return a[k] <=> b[k] unless a[k] == b[k]
32
+ end
33
+ # A version with no prerelease is greater than one with a prerelease.
34
+ return 0 if a[:pre].nil? && b[:pre].nil?
35
+ return 1 if a[:pre].nil?
36
+ return -1 if b[:pre].nil?
37
+ [a[:pre].length, b[:pre].length].max.times do |i|
38
+ x = a[:pre][i]
39
+ y = b[:pre][i]
40
+ return -1 if x.nil?
41
+ return 1 if y.nil?
42
+ xn = x.match?(/\A\d+\z/)
43
+ yn = y.match?(/\A\d+\z/)
44
+ if xn && yn
45
+ return x.to_i <=> y.to_i unless x.to_i == y.to_i
46
+ elsif xn
47
+ return -1 # numeric identifiers rank lower than alphanumeric
48
+ elsif yn
49
+ return 1
50
+ elsif x != y
51
+ return x <=> y
52
+ end
53
+ end
54
+ 0
55
+ end
56
+
57
+ def channel(version)
58
+ return "alpha" if version.include?("-alpha")
59
+ return "beta" if version.include?("-beta")
60
+ "stable"
61
+ end
62
+
63
+ REACH = {
64
+ "alpha" => %w[alpha beta latest],
65
+ "beta" => %w[beta latest],
66
+ "stable" => %w[latest],
67
+ }.freeze
68
+
69
+ current = ARGV[0]
70
+ cur = parse(current)
71
+ exit 0 unless cur
72
+
73
+ begin
74
+ tags = JSON.parse($stdin.read)
75
+ rescue JSON::ParserError, StandardError
76
+ exit 0
77
+ end
78
+ exit 0 unless tags.is_a?(Hash)
79
+
80
+ best = nil
81
+ best_parsed = nil
82
+ REACH[channel(current)].each do |tag|
83
+ raw = tags[tag]
84
+ next unless raw
85
+ parsed = parse(raw)
86
+ next unless parsed
87
+ if compare(parsed, cur) > 0 && (best_parsed.nil? || compare(parsed, best_parsed) > 0)
88
+ best = raw
89
+ best_parsed = parsed
90
+ end
91
+ end
92
+
93
+ print best if best
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Plastic — `uninstall` verb. Runs via `npx @zalom/plastic uninstall` (bin/plastic.js) or directly.
6
+ # Usage: ruby scripts/uninstall.rb [--claude|--codex|--hermes|--all] [--help]
7
+ #
8
+ # Manifest-driven removal of installed files + legacy-plugin migration. Leaves ~/.plastic/
9
+ # (intent store, config, and the versions.json ledger) in place — the ledger is permanent.
10
+
11
+ require_relative "lib/installer_core"
12
+
13
+ class Uninstall < InstallerCore
14
+ def cli(argv = ARGV)
15
+ if argv.include?("--help") || argv.include?("-h")
16
+ show_help
17
+ return 0
18
+ end
19
+
20
+ selected = agent_keys_from(argv)
21
+ selected = ["claude"] if selected.empty?
22
+
23
+ puts "\n\u{1f9e0} Plastic v#{version} — uninstall\n\n"
24
+ handle_uninstall(selected)
25
+ 0
26
+ end
27
+
28
+ private
29
+
30
+ def show_help
31
+ puts <<~HELP
32
+
33
+ plastic uninstall — remove Plastic from an agent
34
+
35
+ Usage:
36
+ npx @zalom/plastic uninstall [options]
37
+
38
+ Agent options:
39
+ --claude Uninstall from Claude Code (default)
40
+ --codex Uninstall from Codex CLI
41
+ --hermes Uninstall from Hermes
42
+ --all Uninstall from all supported agents
43
+
44
+ Other options:
45
+ -h, --help Show this help
46
+
47
+ Leaves your intent store, config, and version ledger (~/.plastic/) in place.
48
+
49
+ HELP
50
+ end
51
+ end
52
+
53
+ exit(Uninstall.new(package_root: ENV["PLASTIC_PACKAGE_ROOT"] || File.expand_path("..", __dir__)).cli(ARGV)) if $PROGRAM_NAME == __FILE__