@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.2

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 (99) hide show
  1. package/PLASTIC.md +163 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +37 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +37 -0
  6. package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
  7. package/agents/{intent-curator.md → plastic-intent-curator.md} +1 -1
  8. package/agents/plastic-planner.md +37 -0
  9. package/agents/plastic-spec-specialist.md +37 -0
  10. package/bin/plastic.js +57 -0
  11. package/bin/test +28 -0
  12. package/deprecations.yml +1 -10
  13. package/hooks/auto-arm +5 -0
  14. package/hooks/bash-gate +3 -0
  15. package/hooks/check-update +12 -8
  16. package/hooks/code-gate +12 -0
  17. package/hooks/create-gate +3 -0
  18. package/hooks/gate-check +3 -1
  19. package/hooks/hooks.json +52 -0
  20. package/hooks/qmd-search +8 -0
  21. package/package.json +2 -2
  22. package/scripts/dashboard.rb +687 -0
  23. package/scripts/doctor.rb +1054 -628
  24. package/scripts/hook-auto-arm +51 -0
  25. package/scripts/hook-bash-gate +41 -0
  26. package/scripts/hook-code-gate +27 -0
  27. package/scripts/hook-continue +15 -114
  28. package/scripts/hook-create-gate +59 -0
  29. package/scripts/hook-gate-check +47 -32
  30. package/scripts/hook-qmd-search +44 -0
  31. package/scripts/hook-session-start +106 -38
  32. package/scripts/install.rb +91 -529
  33. package/scripts/lib/boot_banner.rb +28 -0
  34. package/scripts/lib/bridge.rb +404 -19
  35. package/scripts/lib/installer_core.rb +807 -0
  36. package/scripts/lib/intent_validator.rb +235 -0
  37. package/scripts/lib/qmd_hook.rb +44 -0
  38. package/scripts/lib/qmd_sync.rb +209 -0
  39. package/scripts/lib/store_provisioning.rb +100 -0
  40. package/scripts/migrate-to-global +1 -1
  41. package/scripts/new-intent +226 -0
  42. package/scripts/provision-project-store +53 -0
  43. package/scripts/qmd-sync +92 -0
  44. package/scripts/select-update-target +93 -0
  45. package/scripts/spawn-preamble +121 -0
  46. package/scripts/uninstall.rb +53 -0
  47. package/scripts/update.rb +164 -0
  48. package/scripts/validate-intent +54 -0
  49. package/scripts/versions.rb +141 -0
  50. package/skills/_active-intent-gate.md +1 -1
  51. package/skills/add-project-store/SKILL.md +54 -0
  52. package/skills/auto/SKILL.md +77 -7
  53. package/skills/auto/evals/evals.json +207 -0
  54. package/skills/auto/references/agent-architecture.md +135 -0
  55. package/skills/brainstorming/SKILL.md +9 -9
  56. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  57. package/skills/continuing/SKILL.md +92 -82
  58. package/skills/continuing/evals/evals.json +136 -0
  59. package/skills/continuing/references/context-management.md +32 -0
  60. package/skills/creating-intent/SKILL.md +74 -36
  61. package/skills/creating-intent/evals/evals.json +56 -0
  62. package/skills/creating-intent/references/lifecycle.md +76 -0
  63. package/skills/creating-intent/references/wikilinks.md +8 -0
  64. package/skills/creating-project/SKILL.md +40 -8
  65. package/skills/creating-project/references/hubs-projects.md +55 -0
  66. package/skills/dashboard/SKILL.md +121 -0
  67. package/skills/dashboard/templates/dashboard-global.md +31 -0
  68. package/skills/dashboard/templates/dashboard-project.md +40 -0
  69. package/skills/doctor/SKILL.md +51 -4
  70. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  71. package/skills/doctor/report.md +4 -0
  72. package/skills/evaluating-skills/SKILL.md +140 -0
  73. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  74. package/skills/evaluating-skills/evals/evals.json +75 -0
  75. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  76. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  77. package/skills/executing-plan/SKILL.md +5 -3
  78. package/skills/install/SKILL.md +69 -8
  79. package/skills/intent-curator/SKILL.md +3 -3
  80. package/skills/linking-intents/SKILL.md +16 -7
  81. package/skills/linking-intents/references/zettelkasten.md +38 -0
  82. package/skills/managing-index/SKILL.md +5 -1
  83. package/skills/managing-index/references/zettelkasten-linking.md +1 -1
  84. package/skills/releasing/SKILL.md +80 -23
  85. package/skills/releasing/references/deprecations.md +60 -0
  86. package/skills/research/SKILL.md +2 -2
  87. package/skills/savepoint/SKILL.md +46 -37
  88. package/skills/savepoint/references/context-management.md +32 -0
  89. package/skills/uninstall/SKILL.md +39 -28
  90. package/skills/update/SKILL.md +41 -44
  91. package/skills/versions/SKILL.md +65 -0
  92. package/skills/writing-instructions/SKILL.md +159 -0
  93. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  94. package/skills/writing-plans/SKILL.md +5 -5
  95. package/templates/agents.md +7 -7
  96. package/templates/outcome.md +13 -0
  97. package/templates/savepoint.md +14 -13
  98. package/templates/spec.md +25 -0
  99. package/bin/install.js +0 -29
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # new-intent (intent 60b) - the one-call scaffolding contract.
6
+ #
7
+ # A single invocation scaffolds a COMPLETE intent: it allocates the Folgezettel
8
+ # id (root vs branch by --parent), creates the directory tree (<id>--<slug>/ plus
9
+ # actions/ and resources/), renders the born-complete intent file from
10
+ # templates/intent.md, writes sentinel placeholder lifecycle files (spec.md,
11
+ # plan.md, checklist.md, outcome.md, each carrying <!-- plastic:placeholder -->
12
+ # as its first line), wires reciprocal file links, and self-validates with
13
+ # IntentValidator (exit non-zero if not born complete).
14
+ #
15
+ # It does NOT touch INDEX.md, git, or project creation: those remain skill/agent
16
+ # responsibilities (see plastic-creating-intent).
17
+ #
18
+ # Usage:
19
+ # new-intent --store <store_path> --intent "<one-line>" --slug <slug> \
20
+ # [--parent <id>] [--author <name>] [--sources id,id] \
21
+ # [--tags tag,tag] [--templates <dir>]
22
+ #
23
+ # Exit codes: 0 (born complete), 1 (scaffold failed self-validation or bad input).
24
+
25
+ require "fileutils"
26
+ require "date"
27
+ require_relative "lib/bridge"
28
+ require_relative "lib/intent_validator"
29
+
30
+ # --- Explicit flag parsing (no eval, no global injection) ------------------
31
+
32
+ def parse_args(argv)
33
+ opts = {
34
+ store: nil, intent: nil, slug: nil, parent: nil,
35
+ author: "claude-code", sources: [], tags: [], templates: nil
36
+ }
37
+ i = 0
38
+ while i < argv.length
39
+ arg = argv[i]
40
+ case arg
41
+ when "--store" then opts[:store] = argv[i += 1]
42
+ when "--intent" then opts[:intent] = argv[i += 1]
43
+ when "--slug" then opts[:slug] = argv[i += 1]
44
+ when "--parent" then opts[:parent] = argv[i += 1]
45
+ when "--author" then opts[:author] = argv[i += 1]
46
+ when "--sources" then opts[:sources] = split_list(argv[i += 1])
47
+ when "--tags" then opts[:tags] = split_list(argv[i += 1])
48
+ when "--templates" then opts[:templates] = argv[i += 1]
49
+ else
50
+ abort "new-intent: unknown argument #{arg.inspect}"
51
+ end
52
+ i += 1
53
+ end
54
+ opts
55
+ end
56
+
57
+ def split_list(value)
58
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
59
+ end
60
+
61
+ def expand(path)
62
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
63
+ end
64
+
65
+ # Default templates dir: sibling of this script's dir. Works in-repo
66
+ # (<repo>/scripts/new-intent -> <repo>/templates) and installed
67
+ # (~/.plastic/scripts/new-intent -> ~/.plastic/templates when present).
68
+ def default_templates_dir
69
+ File.expand_path("../templates", __dir__)
70
+ end
71
+
72
+ def render_tokens(text, tokens)
73
+ tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}", v.to_s) }
74
+ end
75
+
76
+ # Add an id to a source intent's frontmatter `chain` array, idempotently (I1
77
+ # reciprocity: `child in parent.sources` => `parent.chain` gains `child`). A
78
+ # targeted edit of the `chain:` line only; the body (including `## Links`) is
79
+ # preserved byte-for-byte and no other frontmatter key is touched, so the file
80
+ # stays born-complete. Renders the array in flow style (`["a", "b"]`) to match
81
+ # templates/intent.md. No-op when the id is already present.
82
+ def add_to_chain(file_path, new_id)
83
+ return unless File.exist?(file_path)
84
+ content = File.read(file_path)
85
+ return unless content.start_with?("---")
86
+
87
+ parts = content.split("---", 3)
88
+ return unless parts.length >= 3
89
+
90
+ fm = parts[1]
91
+ chain_line = fm.lines.find { |l| l.match?(/\A\s*chain\s*:/) }
92
+ return unless chain_line
93
+
94
+ existing = fm.match(/\bchain\s*:\s*\[(.*?)\]/m)
95
+ ids =
96
+ if existing
97
+ existing[1].scan(/"([^"]*)"|'([^']*)'/).flatten.compact
98
+ else
99
+ []
100
+ end
101
+ return if ids.include?(new_id)
102
+
103
+ ids << new_id
104
+ rendered = "chain: [#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
105
+ new_fm = fm.sub(/^\s*chain\s*:.*$/, rendered)
106
+ File.write(file_path, ["", new_fm, parts[2]].join("---"))
107
+ end
108
+
109
+ # Append a wikilink line under the file's `## Links` section, idempotently.
110
+ def append_link(file_path, link_line)
111
+ return unless File.exist?(file_path)
112
+ content = File.read(file_path)
113
+ return if content.include?(link_line)
114
+
115
+ lines = content.lines
116
+ idx = lines.index { |l| l.strip == "## Links" }
117
+ return unless idx
118
+
119
+ insert_at = lines.length
120
+ ((idx + 1)...lines.length).each do |j|
121
+ if lines[j].strip.start_with?("## ")
122
+ insert_at = j
123
+ break
124
+ end
125
+ end
126
+ lines.insert(insert_at, "#{link_line}\n")
127
+ File.write(file_path, lines.join)
128
+ end
129
+
130
+ def main(argv)
131
+ opts = parse_args(argv)
132
+ abort "new-intent: --store is required" if opts[:store].nil? || opts[:store].empty?
133
+ abort "new-intent: --intent is required" if opts[:intent].nil? || opts[:intent].empty?
134
+ abort "new-intent: --slug is required" if opts[:slug].nil? || opts[:slug].empty?
135
+
136
+ store = expand(opts[:store])
137
+ abort "new-intent: store dir does not exist: #{store}" unless Dir.exist?(store)
138
+
139
+ templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
140
+ abort "new-intent: templates dir not found: #{templates}" unless Dir.exist?(templates)
141
+
142
+ # 1. Allocate id via the existing folgezettel-id logic (root vs branch).
143
+ folg = File.expand_path("folgezettel-id", __dir__)
144
+ cmd = [folg, store]
145
+ cmd << opts[:parent] if opts[:parent] && !opts[:parent].empty?
146
+ id = `#{cmd.map { |c| "'#{c}'" }.join(" ")}`.strip
147
+ abort "new-intent: id allocation failed" if id.empty?
148
+
149
+ slug = opts[:slug]
150
+ intent_dir = File.join(store, "#{id}--#{slug}")
151
+ abort "new-intent: #{intent_dir} already exists" if File.exist?(intent_dir)
152
+
153
+ # 2. Create dirs.
154
+ FileUtils.mkdir_p(File.join(intent_dir, "actions"))
155
+ FileUtils.mkdir_p(File.join(intent_dir, "resources"))
156
+
157
+ # 3. Render the born-complete intent file from templates/intent.md.
158
+ sources = opts[:sources]
159
+ sources = sources | [opts[:parent]] if opts[:parent] && !opts[:parent].empty? && !sources.include?(opts[:parent])
160
+ sources_str = sources.map { |s| "\"#{s}\"" }.join(", ")
161
+ tags_str = opts[:tags].map { |t| "\"#{t}\"" }.join(", ")
162
+
163
+ intent_template = File.read(File.join(templates, "intent.md"))
164
+ intent_body = render_tokens(intent_template, {
165
+ "ID" => id,
166
+ "INTENT" => opts[:intent],
167
+ "SOURCES" => sources_str,
168
+ "DATE" => Date.today.iso8601,
169
+ "AUTHOR" => opts[:author],
170
+ "TAGS" => tags_str,
171
+ "DESCRIPTION" => opts[:intent],
172
+ })
173
+ intent_file = File.join(intent_dir, "#{id}--#{slug}.md")
174
+ File.write(intent_file, intent_body)
175
+
176
+ # 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
177
+ # `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
178
+ # `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
179
+ # folds in `--parent`). Scope boundary: 68 fixes ONLY the frontmatter `chain`
180
+ # backlink; the `## Links` wikilink projection for the `--sources` path is intent 72.
181
+ sources.each do |src_id|
182
+ next if src_id.nil? || src_id.empty?
183
+
184
+ src_dir = Dir.glob(File.join(store, "#{src_id}--*")).find { |d| File.directory?(d) }
185
+ next unless src_dir
186
+
187
+ src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
188
+ add_to_chain(src_file, id)
189
+ end
190
+
191
+ # 4b. Reciprocal `## Links` wikilinks: forward link to parent + back-reference in
192
+ # the parent (kept parent-only; the `--sources` path's wikilink is intent 72's scope).
193
+ if opts[:parent] && !opts[:parent].empty?
194
+ parent_id = opts[:parent]
195
+ append_link(intent_file, "- [[#{parent_id}]]")
196
+ parent_dir = Dir.glob(File.join(store, "#{parent_id}--*")).find { |d| File.directory?(d) }
197
+ if parent_dir
198
+ parent_file = File.join(parent_dir, "#{File.basename(parent_dir)}.md")
199
+ append_link(parent_file, "- [[#{id}]]")
200
+ end
201
+ end
202
+
203
+ # 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
204
+ # line; the rendered template body follows so the file is a usable starting
205
+ # point once an agent deletes the sentinel.
206
+ %w[spec.md plan.md checklist.md outcome.md].each do |name|
207
+ template_path = File.join(templates, name)
208
+ body = File.exist?(template_path) ? File.read(template_path) : ""
209
+ body = render_tokens(body, { "INTENT_NAME" => opts[:intent] })
210
+ File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
211
+ end
212
+
213
+ # 6. Self-validate (frontmatter + sanctioned sections).
214
+ result = IntentValidator.validate(intent_dir)
215
+ unless result[:ok]
216
+ warn "new-intent: scaffolded intent is NOT born complete:"
217
+ result[:missing].each { |f| warn " missing field: #{f}" }
218
+ result[:errors].each { |e| warn " #{e}" }
219
+ exit 1
220
+ end
221
+
222
+ puts intent_dir
223
+ exit 0
224
+ end
225
+
226
+ main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -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,121 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # spawn-preamble — deterministic live-state injection for spawned agents
6
+ # (intent 4a1c1, agent harness foundation).
7
+ #
8
+ # Emits a preamble block built ONLY from filesystem state for a single intent
9
+ # directory. It is a PURE function of the intent dir: no network, no randomness,
10
+ # no wall-clock reads. Two runs over the same on-disk state produce byte-identical
11
+ # output. This is the authoritative L2 (live-state) mechanism for harnesses whose
12
+ # spawned sub-agents do not inherit a top-level session event (see
13
+ # docs/reference/harness-adapters.md).
14
+ #
15
+ # Usage:
16
+ # spawn-preamble <intent_dir> [--role ROLE] [--step STEP]
17
+ #
18
+ # The preamble reports the active intent (id + intent line from frontmatter), the
19
+ # current lifecycle stage (last savepoint line if present, else derived from which
20
+ # lifecycle files exist), the cycle role/step (from --role/--step, else the
21
+ # derived stage), and an imperative honoring instruction.
22
+ #
23
+ # Exit codes: 0 (preamble emitted), 2 (usage).
24
+
25
+ require_relative "lib/bridge"
26
+
27
+ # Verbatim honoring instruction. Kept as one constant so the contract doc and the
28
+ # test assert against the exact same string.
29
+ HONOR_INSTRUCTION =
30
+ "You are operating inside Plastic. Use it as your operating scaffold. " \
31
+ "Emit VALID lifecycle artifacts; do not hallucinate intents or stages. " \
32
+ "Your output is a deliverable, not a message."
33
+
34
+ def parse_args(argv)
35
+ role = nil
36
+ step = nil
37
+ positional = []
38
+ i = 0
39
+ while i < argv.length
40
+ case argv[i]
41
+ when "--role"
42
+ role = argv[i + 1]
43
+ i += 2
44
+ when "--step"
45
+ step = argv[i + 1]
46
+ i += 2
47
+ else
48
+ positional << argv[i]
49
+ i += 1
50
+ end
51
+ end
52
+ [positional.first, role, step]
53
+ end
54
+
55
+ # Read the intent file frontmatter via the same parser the validator uses, so the
56
+ # id/intent we report match what the rest of Plastic sees. Returns a Hash (possibly
57
+ # empty) — never raises.
58
+ def frontmatter_for(intent_dir)
59
+ ifile = Bridge.intent_file(intent_dir)
60
+ return {} unless File.exist?(ifile)
61
+
62
+ content = File.read(ifile)
63
+ return {} unless content.start_with?("---")
64
+
65
+ parts = content.split("---", 3)
66
+ return {} if parts.length < 3
67
+
68
+ require "yaml"
69
+ require "date"
70
+ require "time"
71
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
72
+ rescue StandardError
73
+ {}
74
+ end
75
+
76
+ # Current stage label. Prefer the last non-empty line of savepoint.md (the ledger
77
+ # already encodes the furthest-reached milestone). Fall back to the file-derived
78
+ # stage when there is no ledger.
79
+ STAGE_LABELS = {
80
+ "what" => "What", "why" => "Why", "how" => "How",
81
+ "exec" => "Exec", "done" => "Done"
82
+ }.freeze
83
+
84
+ def current_stage(intent_dir)
85
+ ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
86
+ if File.exist?(ledger)
87
+ last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
88
+ return last if last
89
+ end
90
+ STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
91
+ end
92
+
93
+ intent_dir_arg, role, step = parse_args(ARGV)
94
+
95
+ if intent_dir_arg.nil? || intent_dir_arg.empty?
96
+ warn "usage: spawn-preamble <intent_dir> [--role ROLE] [--step STEP]"
97
+ exit 2
98
+ end
99
+
100
+ intent_dir = File.expand_path(intent_dir_arg)
101
+
102
+ fm = frontmatter_for(intent_dir)
103
+ id = fm["id"].to_s.strip
104
+ intent_name = fm["intent"].to_s.strip
105
+ id = "(unknown)" if id.empty?
106
+ intent_name = "(unknown)" if intent_name.empty?
107
+
108
+ stage = current_stage(intent_dir)
109
+ cycle = role || step || stage
110
+
111
+ lines = []
112
+ lines << "=== Plastic spawn preamble ==="
113
+ lines << "Store: #{intent_dir}"
114
+ lines << "Active intent: #{id} - #{intent_name}"
115
+ lines << "Current stage: #{stage}"
116
+ lines << "Cycle step / role: #{cycle}"
117
+ lines << ""
118
+ lines << HONOR_INSTRUCTION
119
+ lines << "=== end preamble ==="
120
+
121
+ puts lines.join("\n")
@@ -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__