@zalom/plastic 1.2.0 → 1.4.0

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 (63) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +68 -6
  3. package/README.md +5 -0
  4. package/agents/plastic-advisor.md +56 -0
  5. package/agents/plastic-enforcer.md +9 -1
  6. package/agents/plastic-faux-advisor.md +174 -0
  7. package/agents/plastic-future-intent-researcher.md +1 -0
  8. package/hooks/hooks.json +5 -0
  9. package/hooks/links-gate +3 -0
  10. package/hooks/statusline +1 -0
  11. package/package.json +1 -1
  12. package/scripts/doctor.rb +164 -58
  13. package/scripts/end-intent +347 -43
  14. package/scripts/hook-links-gate +74 -0
  15. package/scripts/install.rb +8 -0
  16. package/scripts/lib/agent_models.rb +36 -9
  17. package/scripts/lib/bridge.rb +29 -1
  18. package/scripts/lib/config_asks.rb +110 -0
  19. package/scripts/lib/graph_rebuild.rb +30 -6
  20. package/scripts/lib/hook_registry.rb +2 -1
  21. package/scripts/lib/installer_core.rb +130 -23
  22. package/scripts/lib/intent_validator.rb +38 -10
  23. package/scripts/lib/links_gate.rb +140 -0
  24. package/scripts/lib/links_projection.rb +71 -12
  25. package/scripts/lib/power_tools.rb +57 -14
  26. package/scripts/lib/project_validator.rb +113 -0
  27. package/scripts/lib/qmd_hook.rb +12 -8
  28. package/scripts/lib/restore_intent_v1.rb +154 -0
  29. package/scripts/lib/roadmap_queue.rb +1 -1
  30. package/scripts/lib/roadmap_savepoint.rb +38 -10
  31. package/scripts/lib/store_discovery.rb +77 -0
  32. package/scripts/lib/store_provisioning.rb +21 -12
  33. package/scripts/new-intent +10 -12
  34. package/scripts/project-links +132 -35
  35. package/scripts/provision-project-store +18 -5
  36. package/scripts/read-config +1 -0
  37. package/scripts/rebuild-graph +42 -17
  38. package/scripts/restore-intent-v1 +288 -0
  39. package/scripts/roadmap-next +9 -2
  40. package/scripts/roadmap-savepoint +9 -1
  41. package/scripts/update.rb +50 -1
  42. package/scripts/validate-intent +3 -1
  43. package/scripts/validate-project +53 -0
  44. package/scripts/write-config +105 -0
  45. package/skills/agent-advisor/SKILL.md +92 -0
  46. package/skills/agent-advisor/references/advisor-protocol.md +245 -0
  47. package/skills/auto/SKILL.md +26 -12
  48. package/skills/auto/references/end-tail.md +27 -13
  49. package/skills/install/SKILL.md +30 -2
  50. package/skills/intent-creating/SKILL.md +5 -0
  51. package/skills/intent-ending/SKILL.md +49 -36
  52. package/skills/project-creating/SKILL.md +29 -1
  53. package/skills/releasing/SKILL.md +37 -19
  54. package/skills/roadmap/SKILL.md +9 -7
  55. package/skills/roadmap/references/file-format.md +14 -10
  56. package/skills/roadmap/references/operations.md +22 -18
  57. package/skills/roadmap-continuing/SKILL.md +5 -5
  58. package/skills/roadmap-continuing/evals/evals.json +3 -3
  59. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  60. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
  61. package/skills/update/SKILL.md +34 -4
  62. package/templates/config.yml +31 -6
  63. package/templates/roadmap.md +8 -8
@@ -3,13 +3,26 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  # project-links — project each intent's corrected sources/chain graph into its
6
- # canonical I5 `## Links` section, store-wide across the global, plastic, and
7
- # knowdb stores (intent 72). Deterministic and idempotent: a second run over a
8
- # projected store changes zero files. Touches ONLY the `## Links` section of each
9
- # intent file; frontmatter and every other section stay byte-identical.
6
+ # canonical I5 `## Links` section, store-wide across every discovered store
7
+ # (intent 72; intent 189 made discovery store-wide). Deterministic: a second
8
+ # run over a projected store changes zero files (idempotent). Touches ONLY the
9
+ # `## Links` section of each intent file; frontmatter and every other section
10
+ # stay byte-identical.
11
+ #
12
+ # PLASTIC.md's `## Links` contract has two halves: never hand-write a line, and
13
+ # NEVER AUTO-DELETE one. Before intent 192 this tool violated the second half:
14
+ # it rebuilt `## Links` purely from frontmatter, silently discarding any
15
+ # existing line with no sources/chain edge behind it, real relationship or
16
+ # not. Now: a line unbacked by frontmatter that still RESOLVES to a real,
17
+ # currently-discovered intent is an ORPHAN CANDIDATE, preserved by default and
18
+ # reported in the audit, never silently deleted. Pass --drop-unbacked-links to
19
+ # delete orphan candidates deliberately (reported separately from a silent
20
+ # drop). A line that resolves to nothing (a broken or mistyped wikilink) is
21
+ # still dropped silently, exactly as before: it names no real relationship to
22
+ # lose.
10
23
  #
11
24
  # Usage:
12
- # project-links [--plastic-home PATH] [--dry-run] [--audit-path PATH]
25
+ # project-links [--plastic-home PATH] [--dry-run] [--audit-path PATH] [--drop-unbacked-links]
13
26
  #
14
27
  # Pure-Ruby (no bash). The pure logic lives in lib/links_projection.rb and
15
28
  # lib/links_section.rb; this shell does only discovery, IO, and reporting. It reads
@@ -24,6 +37,7 @@ require_relative "lib/intent_validator"
24
37
  require_relative "lib/graph_rebuild"
25
38
  require_relative "lib/links_projection"
26
39
  require_relative "lib/links_section"
40
+ require_relative "lib/store_discovery"
27
41
 
28
42
  class ProjectLinks
29
43
  DEFAULT_HOME = File.join(Dir.home, ".plastic")
@@ -32,9 +46,11 @@ class ProjectLinks
32
46
  DEFAULT_AUDIT_REL =
33
47
  "projects/plastic/store/72--links-graph-projection/resources/audit--links-projection.md"
34
48
 
35
- def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
49
+ def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil,
50
+ drop_unbacked_links: false)
36
51
  @plastic_home = plastic_home
37
52
  @dry_run = dry_run
53
+ @drop_unbacked_links = drop_unbacked_links
38
54
 
39
55
  # A dry run must NOT stomp the canonical audit (humans run --dry-run to review
40
56
  # the plan). With no explicit --audit-path, a dry run writes a `.dry-run.md`
@@ -52,26 +68,23 @@ class ProjectLinks
52
68
  end
53
69
  end
54
70
 
55
- attr_reader :plastic_home, :dry_run, :audit_path
71
+ attr_reader :plastic_home, :dry_run, :audit_path, :drop_unbacked_links
56
72
 
57
- # The three in-scope stores, each as { key:, root:, store:, index: }.
73
+ # Every store in scope: global plus every projects/<slug>/store directory that exists
74
+ # (intent 189). Shares its definition with rebuild-graph, doctor.rb, and new-intent via
75
+ # StoreDiscovery, so this tool can never again disagree with doctor about what exists.
58
76
  def stores
59
- list = []
60
- global_store = File.join(plastic_home, "store")
61
- if File.directory?(global_store)
62
- list << { key: "global", root: plastic_home, store: global_store,
63
- index: File.join(plastic_home, "INDEX.md") }
64
- end
77
+ discovery[:stores]
78
+ end
65
79
 
66
- %w[plastic knowdb].each do |slug|
67
- root = File.join(plastic_home, "projects", slug)
68
- store = File.join(root, "store")
69
- next unless File.directory?(store)
80
+ # Registered projects.yml slugs with no store directory on disk (intent 189 D5).
81
+ # Reported in the audit rather than silently treated as an empty store.
82
+ def missing_stores
83
+ discovery[:missing]
84
+ end
70
85
 
71
- list << { key: "project:#{slug}", root: root, store: store,
72
- index: File.join(root, "INDEX.md") }
73
- end
74
- list
86
+ def discovery
87
+ @discovery ||= StoreDiscovery.discover(plastic_home)
75
88
  end
76
89
 
77
90
  # { id => { sources:, chain:, basename:, label:, path: } } for one store.
@@ -131,7 +144,8 @@ class ProjectLinks
131
144
  end
132
145
 
133
146
  # Project every intent in ONE store. Returns
134
- # { entries: [ {id:, status:, before:, after:, error:} ], counts: {...} }.
147
+ # { entries: [ {id:, status:, before:, after:, error:, orphans_preserved:,
148
+ # orphans_dropped_optin:} ], counts: {...} }.
135
149
  def project_store(referer_store, nodes, relocation_map:, store_index:, node_index:)
136
150
  entries = []
137
151
  nodes.each do |id, node|
@@ -143,38 +157,89 @@ class ProjectLinks
143
157
  end
144
158
 
145
159
  content = File.read(node[:path])
160
+ old_text = extract_links(content) # never raises; ambiguous -> a placeholder string
146
161
 
147
162
  begin
148
163
  had_links = LinksSection.links_heading?(IntentValidator.body_of(content))
149
- section_text = LinksProjection.section(
164
+ canonical_text = LinksProjection.section(
150
165
  sources: node[:sources], chain: node[:chain], resolve: resolve
151
166
  )
167
+ kept, dropped_optin = orphan_split(old_text, canonical_text, referer_store,
168
+ store_index, node_index)
169
+ section_text = LinksProjection.render_entries(
170
+ LinksProjection.parse_entries(canonical_text) + kept
171
+ )
152
172
  updated = LinksSection.rewrite(content, section_text)
153
173
  rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
154
- entries << { id: id, status: :failed, error: e.message }
174
+ entries << { id: id, status: :failed, error: e.message,
175
+ orphans_preserved: [], orphans_dropped_optin: [] }
155
176
  next
156
177
  end
157
178
 
158
179
  if updated == content
159
- entries << { id: id, status: :unchanged }
180
+ entries << { id: id, status: :unchanged,
181
+ orphans_preserved: kept, orphans_dropped_optin: dropped_optin }
160
182
  next
161
183
  end
162
184
 
163
185
  status = had_links ? :regenerated : :added
164
186
  File.write(node[:path], updated) unless dry_run
165
- entries << { id: id, status: status,
166
- before: extract_links(content), after: section_text }
187
+ entries << { id: id, status: status, before: old_text, after: section_text,
188
+ orphans_preserved: kept, orphans_dropped_optin: dropped_optin }
167
189
  end
168
190
 
169
191
  counts = entries.each_with_object(Hash.new(0)) { |e, h| h[e[:status]] += 1 }
170
192
  { entries: entries, counts: counts }
171
193
  end
172
194
 
195
+ # Split the OLD (pre-mutation) `## Links` entries into orphans to KEEP
196
+ # (unbacked by frontmatter but resolve to a real, currently-discovered
197
+ # intent) vs DROP under the explicit --drop-unbacked-links opt-in. An entry
198
+ # that resolves nowhere is neither: it is dropped silently, exactly as before
199
+ # intent 192 (this is dealintell 3b's three broken single-dash lines:
200
+ # garbage, not data). Returns [kept_entries, dropped_optin_entries], both
201
+ # [{target:, label:}].
202
+ def orphan_split(old_text, canonical_text, referer_store, store_index, node_index)
203
+ canonical_targets = LinksProjection.parse_entries(canonical_text).map { |e| e[:target] }
204
+ kept = []
205
+ dropped_optin = []
206
+ LinksProjection.parse_entries(old_text).each do |oe|
207
+ next if canonical_targets.include?(oe[:target]) # already backed; not an orphan
208
+
209
+ label = resolve_orphan_label(oe[:target], referer_store, store_index, node_index)
210
+ next if label.nil? # dead: not backed AND does not resolve; silent drop, as before
211
+
212
+ if drop_unbacked_links
213
+ dropped_optin << oe
214
+ else
215
+ kept << { target: oe[:target], label: label }
216
+ end
217
+ end
218
+ [kept, dropped_optin]
219
+ end
220
+
221
+ # Does `target` (already in projected `id--slug` or `store:id--slug` form)
222
+ # name a real, currently-discovered intent? Returns its CURRENT canonical
223
+ # label when so (self-healing a stale hand-typed label), else nil (genuinely
224
+ # dead/broken, e.g. 3b's single-dash mismatches that no real basename
225
+ # matches).
226
+ def resolve_orphan_label(target, referer_store, store_index, node_index)
227
+ if target.include?(":")
228
+ slug, basename = target.split(":", 2)
229
+ key = LinksProjection.canonical_store_key(slug, store_index)
230
+ else
231
+ key = referer_store
232
+ basename = target
233
+ end
234
+ node = (node_index[key] || {}).values.find { |v| v[:basename] == basename }
235
+ node && node[:label]
236
+ end
237
+
173
238
  # Pull the current REAL `## Links` section text (fence-aware) from a file's
174
- # content, for the audit sample. Returns "" when absent. Delegates to the shared
175
- # LinksSection.extract_section so the audit, the rewriter, and the doctor check
176
- # all agree on the section location (and never match a heading inside an example
177
- # code fence).
239
+ # content, for the audit sample and for orphan-candidate detection. Returns
240
+ # "" when absent. Delegates to the shared LinksSection.extract_section so the
241
+ # audit, the rewriter, and the doctor check all agree on the section
242
+ # location (and never match a heading inside an example code fence).
178
243
  def extract_links(content)
179
244
  LinksSection.extract_section(IntentValidator.body_of(content))
180
245
  rescue LinksSection::AmbiguousLinks
@@ -207,11 +272,22 @@ class ProjectLinks
207
272
  total = STATUS_ORDER.to_h do |st|
208
273
  [st, store_list.sum { |s| results[s[:key]][:counts][st] }]
209
274
  end
275
+ total_preserved = store_list.sum { |s| results[s[:key]][:entries].sum { |e| Array(e[:orphans_preserved]).size } }
276
+ total_dropped_optin = store_list.sum { |s| results[s[:key]][:entries].sum { |e| Array(e[:orphans_dropped_optin]).size } }
210
277
  lines << "Totals across all stores: " \
211
278
  "regenerated #{total[:regenerated]}, added #{total[:added]}, " \
212
- "unchanged #{total[:unchanged]}, failed #{total[:failed]}."
279
+ "unchanged #{total[:unchanged]}, failed #{total[:failed]}. " \
280
+ "Unbacked Links lines preserved as orphan candidates: #{total_preserved}" \
281
+ "#{drop_unbacked_links ? ", dropped via --drop-unbacked-links: #{total_dropped_optin}" : ""}."
213
282
  lines << ""
214
283
 
284
+ unless missing_stores.empty?
285
+ lines << "## Registered projects with no store on disk"
286
+ lines << ""
287
+ missing_stores.each { |m| lines << "- #{m[:slug]} (#{m[:project_dir]})" }
288
+ lines << ""
289
+ end
290
+
215
291
  store_list.each do |s|
216
292
  key = s[:key]
217
293
  res = results[key]
@@ -229,6 +305,22 @@ class ProjectLinks
229
305
  lines << ""
230
306
  end
231
307
 
308
+ preserved = res[:entries].flat_map { |e| Array(e[:orphans_preserved]).map { |o| [e[:id], o] } }
309
+ unless preserved.empty?
310
+ lines << "### Orphan candidates preserved (#{preserved.size})"
311
+ lines << "Unbacked by sources/chain but resolve to a real intent; add the frontmatter " \
312
+ "edge if the relationship is real, or re-run with --drop-unbacked-links if not."
313
+ preserved.each { |id, o| lines << "- #{id}: -> #{o[:target]}" }
314
+ lines << ""
315
+ end
316
+
317
+ dropped_optin = res[:entries].flat_map { |e| Array(e[:orphans_dropped_optin]).map { |o| [e[:id], o] } }
318
+ unless dropped_optin.empty?
319
+ lines << "### Orphan candidates DROPPED (--drop-unbacked-links) (#{dropped_optin.size})"
320
+ dropped_optin.each { |id, o| lines << "- #{id}: -> #{o[:target]}" }
321
+ lines << ""
322
+ end
323
+
232
324
  sample = res[:entries].select { |e| %i[regenerated added].include?(e[:status]) }.first(5)
233
325
  next if sample.empty?
234
326
 
@@ -263,17 +355,22 @@ if $PROGRAM_NAME == __FILE__
263
355
  home = ProjectLinks::DEFAULT_HOME
264
356
  dry = false
265
357
  audit = nil
358
+ drop = false
266
359
  i = 0
267
360
  while i < ARGV.length
268
361
  case ARGV[i]
269
362
  when "--plastic-home" then home = ARGV[i + 1]; i += 2
270
363
  when "--dry-run" then dry = true; i += 1
271
364
  when "--audit-path" then audit = ARGV[i + 1]; i += 2
272
- else i += 1
365
+ when "--drop-unbacked-links" then drop = true; i += 1
366
+ else
367
+ abort "project-links: unknown argument #{ARGV[i].inspect} " \
368
+ "(usage: --plastic-home PATH | --dry-run | --audit-path PATH | --drop-unbacked-links)"
273
369
  end
274
370
  end
275
371
 
276
- tool = ProjectLinks.new(plastic_home: home, dry_run: dry, audit_path: audit)
372
+ tool = ProjectLinks.new(plastic_home: home, dry_run: dry, audit_path: audit,
373
+ drop_unbacked_links: drop)
277
374
  results = tool.run
278
375
  totals = ProjectLinks::STATUS_ORDER.to_h do |st|
279
376
  [st, results.values.sum { |r| r[:counts][st] }]
@@ -12,10 +12,12 @@
12
12
  # nothing.
13
13
  #
14
14
  # Usage:
15
- # provision-project-store <slug> [--home <path>]
15
+ # provision-project-store <slug> [--home <path>] [--package-root <path>]
16
16
  #
17
17
  # Exit codes: 0 (store provisioned), 1 (unregistered slug / error on stderr),
18
18
  # 2 (usage). --home <path> overrides the Plastic home (default: ~/.plastic).
19
+ # --package-root <path> overrides where templates/ resolves from (default:
20
+ # StoreProvisioning::PACKAGE_ROOT, the repo root); mainly for hermetic tests.
19
21
 
20
22
  require_relative "lib/store_provisioning"
21
23
 
@@ -27,22 +29,33 @@ def plastic_home(args)
27
29
  end
28
30
  end
29
31
 
32
+ def package_root(args)
33
+ if (i = args.index("--package-root")) && args[i + 1]
34
+ File.expand_path(args[i + 1])
35
+ else
36
+ StoreProvisioning::PACKAGE_ROOT
37
+ end
38
+ end
39
+
30
40
  home = plastic_home(ARGV)
41
+ root = package_root(ARGV)
31
42
 
32
- # First positional arg, skipping any flag and the value that follows --home.
43
+ # First positional arg, skipping any flag and the value that follows --home
44
+ # or --package-root.
33
45
  home_value_index = (i = ARGV.index("--home")) ? i + 1 : nil
46
+ package_root_value_index = (i = ARGV.index("--package-root")) ? i + 1 : nil
34
47
  slug_index = ARGV.each_index.find do |idx|
35
48
  arg = ARGV[idx]
36
- !arg.start_with?("--") && idx != home_value_index
49
+ !arg.start_with?("--") && idx != home_value_index && idx != package_root_value_index
37
50
  end
38
51
  slug = slug_index && ARGV[slug_index]
39
52
 
40
53
  if slug.nil?
41
- warn "usage: provision-project-store <slug> [--home <path>]"
54
+ warn "usage: provision-project-store <slug> [--home <path>] [--package-root <path>]"
42
55
  exit 2
43
56
  end
44
57
 
45
- result = StoreProvisioning.provision(slug, plastic_home: home)
58
+ result = StoreProvisioning.provision(slug, plastic_home: home, package_root: root)
46
59
 
47
60
  if result[:ok]
48
61
  puts "OK: #{result[:store_dir]}"
@@ -20,6 +20,7 @@ DEFAULTS = {
20
20
  "max_slug_words" => 5,
21
21
  "project_roots" => ["~/.plastic/projects"],
22
22
  "deprecations_dismissed" => [],
23
+ "config_asks_dismissed" => [],
23
24
  "agent" => {
24
25
  "type" => "claude-code",
25
26
  "parallel_mode" => "linear"
@@ -24,6 +24,7 @@ require "fileutils"
24
24
  require_relative "lib/graph_rebuild"
25
25
  require_relative "lib/frontmatter_writer"
26
26
  require_relative "lib/intent_validator"
27
+ require_relative "lib/store_discovery"
27
28
 
28
29
  class RebuildGraph
29
30
  DEFAULT_HOME = File.join(Dir.home, ".plastic")
@@ -65,23 +66,24 @@ class RebuildGraph
65
66
 
66
67
  attr_reader :plastic_home, :dry_run, :audit_path
67
68
 
68
- # The three in-scope stores, each as { key:, root:, store:, index: }.
69
- # `root` is the dir holding INDEX.md; `store` is the intents dir.
69
+ # Every store in scope: global plus every projects/<slug>/store directory that exists
70
+ # (intent 189). A superset of reality, not a hardcoded list: missing a real store here
71
+ # means a live cross-store ref into it gets classified :dead and DELETED (the exact bug
72
+ # this discovery fixes); including one nobody registered is harmless. Single source of
73
+ # truth is StoreDiscovery, shared with project-links, doctor.rb, and new-intent.
70
74
  def stores
71
- list = []
72
- global_store = File.join(plastic_home, "store")
73
- list << { key: "global", root: plastic_home, store: global_store,
74
- index: File.join(plastic_home, "INDEX.md") } if File.directory?(global_store)
75
-
76
- %w[plastic knowdb].each do |slug|
77
- root = File.join(plastic_home, "projects", slug)
78
- store = File.join(root, "store")
79
- next unless File.directory?(store)
80
-
81
- list << { key: "project:#{slug}", root: root, store: store,
82
- index: File.join(root, "INDEX.md") }
83
- end
84
- list
75
+ discovery[:stores]
76
+ end
77
+
78
+ # Registered projects.yml slugs with no store directory on disk (legal;
79
+ # plastic-store-provisioning exists for exactly this state). Reported in the audit
80
+ # (intent 189 D5) so this never looks like a silent empty store.
81
+ def missing_stores
82
+ discovery[:missing]
83
+ end
84
+
85
+ def discovery
86
+ @discovery ||= StoreDiscovery.discover(plastic_home)
85
87
  end
86
88
 
87
89
  # { id => { sources:, chain:, path: } } for one store.
@@ -169,14 +171,33 @@ class RebuildGraph
169
171
  # PURE-ish formatter (string from results). Per-store, grouped by kind.
170
172
  def render_audit(store_list, results)
171
173
  total = store_list.sum { |s| results[s[:key]][:changes].size }
174
+ preserved_unknown = store_list.flat_map { |s| results[s[:key]][:preserved] }
172
175
  lines = []
173
176
  lines << "# Audit: store-wide sources/chain graph rebuild (intent 49)"
174
177
  lines << ""
175
178
  lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
176
179
  lines << ""
177
180
  lines << "Total changes across all stores: #{total}"
181
+ lines << "Unknown-store refs preserved (NOT dropped): #{preserved_unknown.size}"
178
182
  lines << ""
179
183
 
184
+ unless preserved_unknown.empty?
185
+ lines << "## Unknown-store refs preserved (store not recognized this run; check " \
186
+ "projects.yml and store discovery before trusting a zero-change result)"
187
+ lines << ""
188
+ preserved_unknown.each do |p|
189
+ lines << "- #{p[:intent]}.#{p[:field]}: #{p[:ref]} (store #{p[:store].inspect} not recognized)"
190
+ end
191
+ lines << ""
192
+ end
193
+
194
+ unless missing_stores.empty?
195
+ lines << "## Registered projects with no store on disk"
196
+ lines << ""
197
+ missing_stores.each { |m| lines << "- #{m[:slug]} (#{m[:project_dir]})" }
198
+ lines << ""
199
+ end
200
+
180
201
  store_list.each do |s|
181
202
  key = s[:key]
182
203
  changes = results[key][:changes]
@@ -232,13 +253,17 @@ if $PROGRAM_NAME == __FILE__
232
253
  when "--plastic-home" then home = ARGV[i + 1]; i += 2
233
254
  when "--dry-run" then dry = true; i += 1
234
255
  when "--audit-path" then audit = ARGV[i + 1]; i += 2
235
- else i += 1
256
+ else
257
+ abort "rebuild-graph: unknown argument #{ARGV[i].inspect} " \
258
+ "(usage: --plastic-home PATH | --dry-run | --audit-path PATH)"
236
259
  end
237
260
  end
238
261
 
239
262
  tool = RebuildGraph.new(plastic_home: home, dry_run: dry, audit_path: audit)
240
263
  results = tool.run
241
264
  total = results.values.sum { |r| r[:changes].size }
265
+ preserved = results.values.sum { |r| r[:preserved].size }
242
266
  puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
267
+ puts "Unknown-store refs preserved (not dropped): #{preserved}" if preserved.positive?
243
268
  puts "Audit: #{tool.audit_path}"
244
269
  end