@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
@@ -0,0 +1,154 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_rebuild"
5
+ require_relative "frontmatter_writer"
6
+
7
+ # RestoreIntentV1 - pure graph math for restoring a completed intent's frontmatter
8
+ # graph across a v1 prose revert (intent 193). No file IO, no git, no `system`.
9
+ #
10
+ # The rule this module carries: prose reverts to v1; the sources/chain graph is
11
+ # APPEND-ONLY and is the UNION of the v1 snapshot and the current snapshot, never
12
+ # a re-derivation from other intents' reciprocal edges (that would silently erase
13
+ # legitimate I2-asymmetry edges doctor.rb never auto-fixes). Before the union is
14
+ # written, every edge (from either snapshot) is target-resolved by reusing
15
+ # GraphRebuild.resolve_ref verbatim, the same classifier rebuild-graph and
16
+ # doctor.rb already share: a :dead edge (resolves to no id in any known store) is
17
+ # dropped and reported; :same_store, :cross_store, and :unknown_store edges are
18
+ # all kept (bias toward preserving an edge that might be real; only positive proof
19
+ # of non-existence justifies a drop). The value WRITTEN for a kept edge is the
20
+ # RESOLVED value GraphRebuild returns (classification[:id] for :same_store,
21
+ # classification[:ref] for :cross_store), never the raw pre-resolution ref, so
22
+ # this tool can never disagree with rebuild-graph/doctor about the canonical form
23
+ # of an edge it just wrote (D14).
24
+ module RestoreIntentV1
25
+ module_function
26
+
27
+ # PURE. Computes the desired sources/chain for a restore.
28
+ #
29
+ # Returns:
30
+ # { sources: [...], chain: [...],
31
+ # dropped: [ { field: :sources|:chain, ref: "<id or store:id>" }, ... ],
32
+ # unverified: [ { field: :sources|:chain, ref: "<id or store:id>" }, ... ],
33
+ # current_only: [ { field: :sources|:chain, ref: "<id or store:id>" }, ... ] }
34
+ #
35
+ # `current_only` names every edge present in the CURRENT snapshot but absent from
36
+ # the v1 snapshot (D3 transparency): an edge added by the very change being
37
+ # reverted, which the union now carries forward. Named explicitly regardless of
38
+ # its target-resolution outcome, so it is never a silent side effect.
39
+ def compute_graph(v1_sources:, v1_chain:, current_sources:, current_chain:,
40
+ referer_store:, relocation_map:, store_index:)
41
+ sources_result = resolve_union(v1_sources, current_sources, :sources,
42
+ referer_store, relocation_map, store_index)
43
+ chain_result = resolve_union(v1_chain, current_chain, :chain,
44
+ referer_store, relocation_map, store_index)
45
+
46
+ {
47
+ sources: sources_result[:kept],
48
+ chain: chain_result[:kept],
49
+ dropped: sources_result[:dropped] + chain_result[:dropped],
50
+ unverified: sources_result[:unverified] + chain_result[:unverified],
51
+ current_only: current_only_edges(v1_sources, current_sources, :sources) +
52
+ current_only_edges(v1_chain, current_chain, :chain),
53
+ }
54
+ end
55
+
56
+ # PURE. Union two edge arrays (deduped, order-preserving, first array's order
57
+ # wins for shared entries), then target-resolve each via GraphRebuild.resolve_ref.
58
+ # WRITES THE RESOLVED VALUE, not the raw union member: a redundant same-store
59
+ # prefix (e.g. "global:15" written by a "global" intent) collapses to the bare
60
+ # "15", and a relocated ref is repointed to its resolved "store:id" form, exactly
61
+ # matching what GraphRebuild.rebuild_store itself writes (res[:id] / res[:ref]).
62
+ # Resolution can make two distinct union members collapse to the same resolved
63
+ # value, so `kept` is de-duped again after resolution.
64
+ def resolve_union(v1_edges, current_edges, field, referer_store, relocation_map, store_index)
65
+ union = (Array(v1_edges).map(&:to_s) + Array(current_edges).map(&:to_s)).uniq
66
+ kept = []
67
+ dropped = []
68
+ unverified = []
69
+
70
+ union.each do |ref|
71
+ classification = GraphRebuild.resolve_ref(
72
+ ref, referer_store: referer_store, relocation_map: relocation_map, store_index: store_index
73
+ )
74
+ case classification[:status]
75
+ when :dead
76
+ dropped << { field: field, ref: ref }
77
+ when :unknown_store
78
+ kept << ref
79
+ unverified << { field: field, ref: ref }
80
+ when :same_store
81
+ kept << classification[:id]
82
+ when :cross_store
83
+ kept << classification[:ref]
84
+ end
85
+ end
86
+
87
+ { kept: kept.uniq, dropped: dropped, unverified: unverified }
88
+ end
89
+
90
+ # PURE. Every edge present in `current_edges` but absent from `v1_edges`
91
+ # (raw, before target resolution): the set the restore is about to carry
92
+ # forward that v1 itself never had.
93
+ def current_only_edges(v1_edges, current_edges, field)
94
+ v1_set = Array(v1_edges).map(&:to_s)
95
+ Array(current_edges).map(&:to_s).uniq.reject { |ref| v1_set.include?(ref) }
96
+ .map { |ref| { field: field, ref: ref } }
97
+ end
98
+
99
+ # PURE. Reapply the computed graph onto v1's exact prose. Delegates entirely to
100
+ # FrontmatterWriter; this module never rewrites YAML itself.
101
+ def apply_graph(v1_content, desired_sources:, desired_chain:)
102
+ FrontmatterWriter.rewrite_arrays(v1_content, sources: desired_sources, chain: desired_chain)
103
+ end
104
+
105
+ # PURE. Render one revisions.md entry (intent 107's append-only, move-and-record
106
+ # convention). `n` is the next revision number for this intent's revisions.md.
107
+ # `files` names every file reverted to its v1 content in this restore.
108
+ def render_revision_entry(n, at:, timestamp:, files:, before_sources:, after_sources:,
109
+ before_chain:, after_chain:, dropped:)
110
+ lines = []
111
+ lines << "## Revision v#{n} - #{timestamp}"
112
+ lines << "- Why: restore-to-v1 preserved the frontmatter graph across a completed-intent " \
113
+ "restore [rule: restored-to-v1]"
114
+ lines << "- Prior location: frontmatter - sources/chain; prose reverted to ref #{at}"
115
+ lines << "- Files reverted to v1: #{files.empty? ? "(none, already at v1)" : files.join(", ")}"
116
+ lines << "- Change: sources (before: #{before_sources.inspect} -> after: #{after_sources.inspect}); " \
117
+ "chain (before: #{before_chain.inspect} -> after: #{after_chain.inspect})"
118
+ unless dropped.empty?
119
+ lines << ""
120
+ dropped.each do |d|
121
+ lines << " Dropped dead edge in #{d[:field]} -> #{d[:ref]}: target intent does not exist."
122
+ end
123
+ end
124
+ "#{lines.join("\n")}\n"
125
+ end
126
+
127
+ # PURE. Render the dry-run/apply human-readable report. `v1` and `current` are
128
+ # { sources:, chain: } snapshots shown alongside the resulting union so a
129
+ # reviewer can see all three shapes without recomputing anything by hand (spec
130
+ # acceptance criterion: dry-run prints the v1 graph, the current graph, and the
131
+ # resulting union, not only the union).
132
+ def render_report(base:, at:, prose_changes:, v1:, current:, graph:, apply:)
133
+ lines = []
134
+ lines << "restore-intent-v1: #{base} at #{at} (#{apply ? "APPLY" : "DRY RUN"})"
135
+ prose_changes.each { |f| lines << " prose: revert #{f}" }
136
+ lines << " v1 sources -> #{v1[:sources].inspect}"
137
+ lines << " v1 chain -> #{v1[:chain].inspect}"
138
+ lines << " current sources -> #{current[:sources].inspect}"
139
+ lines << " current chain -> #{current[:chain].inspect}"
140
+ lines << " union sources -> #{graph[:sources].inspect}"
141
+ lines << " union chain -> #{graph[:chain].inspect}"
142
+ graph[:dropped].each do |d|
143
+ lines << " DROPPED dead edge (#{d[:field]}): #{d[:ref]} - target intent does not exist"
144
+ end
145
+ graph[:unverified].each do |d|
146
+ lines << " UNVERIFIED edge (#{d[:field]}): #{d[:ref]} - store unknown, kept"
147
+ end
148
+ graph[:current_only].each do |d|
149
+ lines << " CURRENT-ONLY edge (#{d[:field]}): #{d[:ref]} - added by the change being " \
150
+ "reverted, now surviving the restore"
151
+ end
152
+ lines.join("\n")
153
+ end
154
+ end
@@ -112,7 +112,7 @@ class RoadmapQueue
112
112
 
113
113
  def parse_roadmap(path)
114
114
  text = File.read(path)
115
- { slug: File.basename(path, ".md"), path: path, waves: parse_waves(section_body(text, "Waves")) }
115
+ { slug: File.basename(path, ".md"), path: path, waves: parse_waves(RoadmapSavepoint.grouping_section_body(text, path: path)) }
116
116
  end
117
117
 
118
118
  def parse_waves(waves_body)
@@ -21,12 +21,20 @@ require "fileutils"
21
21
  module RoadmapSavepoint
22
22
  module_function
23
23
 
24
- EVENTS = %w[created dispatched parked merged release handoff closed added reordered wave].freeze
24
+ EVENTS = %w[created dispatched parked merged release handoff closed added reordered wave batch].freeze
25
+
26
+ # Raised by grouping_section_body when a roadmap has neither '## Batches' (canonical, owner
27
+ # ruling 145) nor '## Waves' (legacy) as its top-level grouping heading (intent 196): a
28
+ # malformed roadmap must fail loudly, never silently parse as zero entries.
29
+ class MissingGroupingHeading < StandardError; end
30
+
31
+ # Canonical first, legacy fallback second. A roadmap file has exactly one of these, never both.
32
+ GROUPING_HEADINGS = %w[Batches Waves].freeze
25
33
 
26
34
  # Keyword -> event classification for `rebuild`, checked top to bottom, first match wins.
27
35
  # Kept small and deterministic (action 1). Order matters: more specific/rarer words are
28
- # checked before the broader "wave" fallback so an incidental "wave" mention in an otherwise
29
- # classifiable line never shadows its real event.
36
+ # checked before the broader "wave"/"batch" fallbacks so an incidental "wave" or "batch"
37
+ # mention in an otherwise classifiable line never shadows its real event.
30
38
  KEYWORD_TABLE = [
31
39
  [/\bclosed\b/i, "closed"],
32
40
  [/\bhanded off\b|\bhandoff\b/i, "handoff"],
@@ -38,6 +46,7 @@ module RoadmapSavepoint
38
46
  [/\badded\b|\badds\b/i, "added"],
39
47
  [/\bcreated\b/i, "created"],
40
48
  [/\bwave\b/i, "wave"],
49
+ [/\bbatch(?:es)?\b/i, "batch"],
41
50
  ].freeze
42
51
 
43
52
  # --- append -----------------------------------------------------------------
@@ -88,15 +97,16 @@ module RoadmapSavepoint
88
97
  # --- rebuild ------------------------------------------------------------------
89
98
 
90
99
  # Reconstruct the paired ledger deterministically from the roadmap file's `## Log` (never the
91
- # roadmap `.md`, which is read-only here), cross-checked against `## Waves` and the tier's
92
- # INDEX so every `delivered` wave entry has a `merged` line. Every timestamp comes from an
93
- # on-disk source (the Log, or INDEX `## Completed`); an entry with no recoverable timestamp is
94
- # not emitted (D4, never invented). Overwrites the ledger (the one operation allowed to rewrite
95
- # it, matching `Bridge.rebuild_savepoint`). Returns the number of lines written.
100
+ # roadmap `.md`, which is read-only here), cross-checked against the roadmap's grouping
101
+ # section (`## Batches`, or legacy `## Waves`) and the tier's INDEX so every `delivered` wave
102
+ # entry has a `merged` line. Every timestamp comes from an on-disk source (the Log, or INDEX
103
+ # `## Completed`); an entry with no recoverable timestamp is not emitted (D4, never invented).
104
+ # Overwrites the ledger (the one operation allowed to rewrite it, matching
105
+ # `Bridge.rebuild_savepoint`). Returns the number of lines written.
96
106
  def rebuild(roadmap_path)
97
107
  text = File.read(roadmap_path)
98
108
  log_lines = classify_log(section_body(text, "Log"))
99
- delivered_ids = delivered_wave_ids(section_body(text, "Waves"))
109
+ delivered_ids = delivered_wave_ids(grouping_section_body(text, path: roadmap_path))
100
110
  backfilled = backfill_merged_lines(log_lines, delivered_ids, roadmap_path)
101
111
 
102
112
  lines = dedup_pairs(log_lines + backfilled)
@@ -139,7 +149,8 @@ module RoadmapSavepoint
139
149
 
140
150
  WAVE_ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+.+—\s*(\S+)\s*\z/.freeze
141
151
 
142
- # Intent ids of every `[x] ... — delivered` entry in the `## Waves` body.
152
+ # Intent ids of every `[x] ... — delivered` entry in the roadmap's grouping section body
153
+ # (`## Batches`, or legacy `## Waves`).
143
154
  def delivered_wave_ids(waves_body)
144
155
  waves_body.each_line.filter_map do |line|
145
156
  m = line.strip.match(WAVE_ENTRY)
@@ -198,6 +209,23 @@ module RoadmapSavepoint
198
209
  end
199
210
  private_class_method :section_body
200
211
 
212
+ # The one shared fix point for the Batches/Waves grammar (intent 196). '## Batches' is
213
+ # canonical (owner ruling 145); '## Waves' is the legacy heading the three pre-ruling roadmaps
214
+ # still use and must keep parsing forever (145 also forbids renaming those files). Public,
215
+ # because roadmap_queue.rb calls it instead of holding its own copy of the heading string: that
216
+ # file already depends one-directionally on this module (require_relative "roadmap_savepoint",
217
+ # already calling `ledger_path_for`), so this is the smaller diff than a new shared module.
218
+ # Raises MissingGroupingHeading, naming the offending path, when neither heading is present.
219
+ def grouping_section_body(text, path: nil)
220
+ GROUPING_HEADINGS.each do |heading|
221
+ m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
222
+ return m[1] if m
223
+ end
224
+ raise MissingGroupingHeading,
225
+ "#{path || '(unknown roadmap file)'}: found neither '## Batches' (canonical) nor " \
226
+ "'## Waves' (legacy) grouping heading"
227
+ end
228
+
201
229
  # Stable dedup on the `(event, detail)` pair, keeping the first occurrence in the given
202
230
  # (already chronological-then-backfill-appended) order.
203
231
  def dedup_pairs(lines)
@@ -0,0 +1,77 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "store_provisioning"
5
+
6
+ # StoreDiscovery: the single source of truth for "what stores exist" (intent 189).
7
+ #
8
+ # Two failure modes must both be avoided: missing a real store (a live cross-store ref
9
+ # into it gets classified dead and DELETED by rebuild-graph, the data-loss bug this module
10
+ # fixes) and silently treating a registered-but-unprovisioned project as an empty store (a
11
+ # different silent failure). So discovery is a SUPERSET: the global store (if it exists)
12
+ # plus every `projects/<slug>/store` directory that exists on disk, UNIONED with every slug
13
+ # registered in projects.yml. A registered slug with no store directory contributes no ids
14
+ # and is reported separately in `missing`, never silently dropped.
15
+ #
16
+ # Reuses StoreProvisioning.load_projects (rescues to {} so a malformed projects.yml never
17
+ # raises) instead of writing a third copy of that reader (a second copy already exists in
18
+ # QmdSync, out of scope here).
19
+ #
20
+ # Pure filesystem, dependency-injected: `discover` takes `plastic_home` as its only
21
+ # argument, performs no writes, no `system`/`spawn`, no network, no eval, no
22
+ # ENV/global-constant reads.
23
+ module StoreDiscovery
24
+ module_function
25
+
26
+ # Returns { stores: [ { key:, slug:, root:, store:, index: } ... ],
27
+ # missing: [ { slug:, project_dir: } ... ] }.
28
+ #
29
+ # `stores` entries: `key` is "global" or "project:<slug>" (the store_index/referer_store
30
+ # key shape GraphRebuild and the doctor checks already use); `slug` is the bare token
31
+ # form used in a cross-store ref ("global", "knowdb", "ai-agents-resources"); `root` is
32
+ # the directory holding INDEX.md; `store` is the intents directory; `index` is the
33
+ # INDEX.md path. Sorted by slug (global first) for deterministic output.
34
+ #
35
+ # `missing` lists every projects.yml slug with no `store/` directory on disk: legal
36
+ # (plastic-store-provisioning exists for exactly this state), reported so callers never
37
+ # mistake it for a store with zero intents.
38
+ def discover(plastic_home)
39
+ stores = []
40
+ missing = []
41
+
42
+ global_store = File.join(plastic_home, "store")
43
+ if File.directory?(global_store)
44
+ stores << { key: "global", slug: "global", root: plastic_home,
45
+ store: global_store, index: File.join(plastic_home, "INDEX.md") }
46
+ end
47
+
48
+ registered = StoreProvisioning.load_projects(plastic_home) # { slug => info }, {} on error/absence
49
+ projects_root = File.join(plastic_home, "projects")
50
+ on_disk = File.directory?(projects_root) ? Dir.children(projects_root).reject { |e| e.start_with?(".") } : []
51
+
52
+ all_slugs = (registered.keys + on_disk).uniq.sort
53
+
54
+ all_slugs.each do |slug|
55
+ root = File.join(projects_root, slug)
56
+ store_dir = File.join(root, "store")
57
+ if File.directory?(store_dir)
58
+ stores << { key: "project:#{slug}", slug: slug, root: root,
59
+ store: store_dir, index: File.join(root, "INDEX.md") }
60
+ elsif registered.key?(slug)
61
+ missing << { slug: slug, project_dir: root }
62
+ end
63
+ # else: an on-disk directory with no store/ and no projects.yml entry (a junk dir,
64
+ # e.g. a stale path-as-slug from a past bug). Silently excluded, exactly as doctor's
65
+ # existing disk scan already does: it is neither a store nor a registered project.
66
+ end
67
+
68
+ { stores: stores, missing: missing }
69
+ end
70
+
71
+ # Convenience: just the known store SLUGS (the token form used in a cross-store ref),
72
+ # for IntentValidator's injected known-store check (ACTION_7). "global" is included
73
+ # when the global store exists.
74
+ def known_slugs(plastic_home)
75
+ discover(plastic_home)[:stores].map { |s| s[:slug] }
76
+ end
77
+ end
@@ -44,24 +44,33 @@ module StoreProvisioning
44
44
  }
45
45
  end
46
46
 
47
+ index_template = File.join(package_root, "templates", "index.md")
48
+ project_template = File.join(package_root, "templates", "project.yml")
49
+
50
+ missing = []
51
+ missing << "templates/index.md" unless File.exist?(index_template)
52
+ missing << "templates/project.yml" unless File.exist?(project_template)
53
+
54
+ unless missing.empty?
55
+ return {
56
+ ok: false,
57
+ error: "cannot provision project '#{slug}': missing required " \
58
+ "template(s) #{missing.join(", ")} under #{package_root}/templates. " \
59
+ "This means the installer did not ship these templates to " \
60
+ "package_root/templates: check InstallerCore#core_files registers " \
61
+ "every templates/* file, then re-run the Plastic installer (or " \
62
+ "'plastic update') so package_root has current templates.",
63
+ }
64
+ end
65
+
47
66
  project_dir = File.join(plastic_home, "projects", slug)
48
67
  store_dir = File.join(project_dir, "store")
49
68
  FileUtils.mkdir_p(store_dir)
50
69
 
51
70
  created = []
52
71
  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
72
+ created << write_if_missing(File.join(project_dir, "INDEX.md"), File.read(index_template))
73
+ created << write_if_missing(File.join(project_dir, "project.yml"), File.read(project_template))
65
74
 
66
75
  { ok: true, store_dir: store_dir, created: created.compact }
67
76
  end
@@ -29,6 +29,7 @@ require_relative "lib/intent_validator"
29
29
  require_relative "lib/graph_rebuild"
30
30
  require_relative "lib/links_projection"
31
31
  require_relative "lib/links_section"
32
+ require_relative "lib/store_discovery"
32
33
 
33
34
  # --- Explicit flag parsing (no eval, no global injection) ------------------
34
35
 
@@ -162,17 +163,12 @@ def store_context(store)
162
163
  end
163
164
  end
164
165
 
165
- # The in-scope stores under `plastic_home`, each { key:, store: }. Mirrors
166
- # project-links/RebuildGraph#stores so cross-store resolution spans the family.
166
+ # The in-scope stores under `plastic_home`, each { key:, store: }. Delegates to
167
+ # StoreDiscovery, the single source of truth also used by project-links, rebuild-graph,
168
+ # and doctor.rb (intent 189), so cross-store resolution spans EVERY real store, not a
169
+ # hardcoded few.
167
170
  def family_stores(plastic_home)
168
- list = []
169
- global_store = File.join(plastic_home, "store")
170
- list << { key: "global", store: global_store } if File.directory?(global_store)
171
- %w[plastic knowdb].each do |slug|
172
- store = File.join(plastic_home, "projects", slug, "store")
173
- list << { key: "project:#{slug}", store: store } if File.directory?(store)
174
- end
175
- list
171
+ StoreDiscovery.discover(plastic_home)[:stores].map { |s| { key: s[:key], store: s[:store] } }
176
172
  end
177
173
 
178
174
  # Build the cross-store maps the LinksProjection resolver needs:
@@ -353,8 +349,10 @@ def main(argv)
353
349
  # fire adds nothing.
354
350
  Bridge.append_savepoint(intent_dir, intent_file)
355
351
 
356
- # 7. Self-validate (frontmatter + sanctioned sections).
357
- result = IntentValidator.validate(intent_dir)
352
+ # 7. Self-validate (frontmatter + sanctioned sections), including that every
353
+ # sources/chain cross-store token names a real store (intent 189 D3).
354
+ known_stores = StoreDiscovery.known_slugs(plastic_home)
355
+ result = IntentValidator.validate(intent_dir, known_stores: known_stores)
358
356
  unless result[:ok]
359
357
  warn "new-intent: scaffolded intent is NOT born complete:"
360
358
  result[:missing].each { |f| warn " missing field: #{f}" }