@zalom/plastic 1.3.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 (52) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +24 -3
  3. package/hooks/hooks.json +5 -0
  4. package/hooks/links-gate +3 -0
  5. package/package.json +1 -1
  6. package/scripts/doctor.rb +164 -58
  7. package/scripts/end-intent +347 -43
  8. package/scripts/hook-links-gate +74 -0
  9. package/scripts/lib/bridge.rb +29 -1
  10. package/scripts/lib/config_asks.rb +110 -0
  11. package/scripts/lib/graph_rebuild.rb +30 -6
  12. package/scripts/lib/hook_registry.rb +2 -1
  13. package/scripts/lib/installer_core.rb +30 -7
  14. package/scripts/lib/intent_validator.rb +38 -10
  15. package/scripts/lib/links_gate.rb +140 -0
  16. package/scripts/lib/links_projection.rb +71 -12
  17. package/scripts/lib/power_tools.rb +57 -14
  18. package/scripts/lib/project_validator.rb +113 -0
  19. package/scripts/lib/qmd_hook.rb +12 -8
  20. package/scripts/lib/restore_intent_v1.rb +154 -0
  21. package/scripts/lib/roadmap_queue.rb +1 -1
  22. package/scripts/lib/roadmap_savepoint.rb +38 -10
  23. package/scripts/lib/store_discovery.rb +77 -0
  24. package/scripts/lib/store_provisioning.rb +21 -12
  25. package/scripts/new-intent +10 -12
  26. package/scripts/project-links +132 -35
  27. package/scripts/provision-project-store +18 -5
  28. package/scripts/read-config +1 -0
  29. package/scripts/rebuild-graph +42 -17
  30. package/scripts/restore-intent-v1 +288 -0
  31. package/scripts/roadmap-next +9 -2
  32. package/scripts/roadmap-savepoint +9 -1
  33. package/scripts/update.rb +50 -1
  34. package/scripts/validate-intent +3 -1
  35. package/scripts/validate-project +53 -0
  36. package/scripts/write-config +105 -0
  37. package/skills/auto/SKILL.md +16 -10
  38. package/skills/auto/references/end-tail.md +27 -13
  39. package/skills/install/SKILL.md +4 -4
  40. package/skills/intent-creating/SKILL.md +5 -0
  41. package/skills/intent-ending/SKILL.md +49 -36
  42. package/skills/project-creating/SKILL.md +29 -1
  43. package/skills/releasing/SKILL.md +37 -19
  44. package/skills/roadmap/SKILL.md +9 -7
  45. package/skills/roadmap/references/file-format.md +14 -10
  46. package/skills/roadmap/references/operations.md +22 -18
  47. package/skills/roadmap-continuing/SKILL.md +5 -5
  48. package/skills/roadmap-continuing/evals/evals.json +3 -3
  49. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  50. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
  51. package/skills/update/SKILL.md +30 -17
  52. package/templates/roadmap.md +8 -8
@@ -3,17 +3,18 @@
3
3
 
4
4
  require_relative "qmd_sync"
5
5
 
6
- # PowerTools detect-then-degrade harness for Plastic's optional power-tools
7
- # (intent 66b; demoted to recommendations in intent 108, D8). It owns
8
- # deterministic detection of each tool and builds a RECOMMENDATION string for
9
- # whichever tools are present, so the agent is reminded (not obliged) to prefer
10
- # them: QMD for finding intents, Serena for code navigation.
6
+ # PowerTools - detect-then-degrade harness for Plastic's optional power-tools
7
+ # (intent 66b; demoted to recommendations in intent 108, D8; Enola added in
8
+ # intent 187). It owns deterministic detection of each tool and builds a
9
+ # RECOMMENDATION string for whichever tools are present, so the agent is
10
+ # reminded (not obliged) to prefer them: QMD for finding intents, Enola or
11
+ # Serena for code navigation.
11
12
  #
12
13
  # Strictly detect-then-degrade: a tool that is absent contributes nothing, and
13
14
  # `mandate` returns nil when no tool is present. Nothing here installs anything.
14
15
  #
15
16
  # Pure and dependency-injected: every detection runs through an injected callable
16
- # or keyword probe (PATH scan / `.serena` marker walk), so the whole module is
17
+ # or keyword probe (PATH scan / marker-directory walk), so the whole module is
17
18
  # unit-testable with no real binaries, no network, and no global/ENV state.
18
19
  module PowerTools
19
20
  module_function
@@ -31,6 +32,15 @@ module PowerTools
31
32
  !!path_probe.call
32
33
  end
33
34
 
35
+ # True when Enola is present: a `.enola` directory exists in cwd or any
36
+ # ancestor (a generated snapshot), OR `enola` is resolvable on PATH. Both
37
+ # probes are injectable so tests do not depend on the host having Enola
38
+ # installed or indexed (intent 187).
39
+ def enola?(cwd:, path_probe: method(:which_enola), marker_finder: method(:enola_marker?))
40
+ return true if marker_finder.call(cwd)
41
+ !!path_probe.call
42
+ end
43
+
34
44
  # True when `serena` is an executable on PATH. Mirrors QmdSync.which_qmd.
35
45
  def which_serena
36
46
  ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
@@ -39,6 +49,14 @@ module PowerTools
39
49
  end
40
50
  end
41
51
 
52
+ # True when `enola` is an executable on PATH. Mirrors which_serena.
53
+ def which_enola
54
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
55
+ candidate = File.join(dir, "enola")
56
+ File.file?(candidate) && File.executable?(candidate)
57
+ end
58
+ end
59
+
42
60
  # Walk up from cwd to the filesystem root, returning true if any level holds a
43
61
  # `.serena` directory.
44
62
  def serena_marker?(cwd)
@@ -52,26 +70,51 @@ module PowerTools
52
70
  false
53
71
  end
54
72
 
73
+ # Walk up from cwd to the filesystem root, returning true if any level holds
74
+ # an `.enola` directory (a generated snapshot).
75
+ def enola_marker?(cwd)
76
+ dir = File.expand_path(cwd)
77
+ loop do
78
+ return true if Dir.exist?(File.join(dir, ".enola"))
79
+ parent = File.dirname(dir)
80
+ break if parent == dir
81
+ dir = parent
82
+ end
83
+ false
84
+ end
85
+
55
86
  QMD_OBLIGATION = "prefer `qmd search` / `qmd query` over the `plastic-*` " \
56
87
  "collections to check for existing or related intents before " \
57
88
  "treating work as new"
58
89
  SERENA_OBLIGATION = "prefer its symbolic tools (find_symbol / get_symbols_overview / " \
59
90
  "find_referencing_symbols) for code navigation"
91
+ ENOLA_OBLIGATION = "prefer its MCP symbol resolution (or `.enola/facts.jsonl`) for " \
92
+ "code navigation over grep"
60
93
 
61
94
  # Recommendation text for whichever tools are present, or nil when none are.
62
- # Both present collapse to ONE combined line naming both obligations (no
63
- # embedded newline); one present returns that tool's own line; neither
64
- # returns nil.
65
- def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil)
95
+ # QMD plus a code-navigation tool collapse to ONE combined line naming both
96
+ # obligations (no embedded newline); one tool present returns that tool's own
97
+ # line; neither returns nil.
98
+ #
99
+ # Enola-first: Enola and Serena share ONE code-navigation slot. When both are
100
+ # present, only Enola is named (intent 187, matching the owner's standing
101
+ # Enola-first ruling and avoiding a bloated three-tool line). The QMD-only and
102
+ # Serena-only lines are unchanged from before Enola existed.
103
+ def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil, enola_detector: nil)
66
104
  qmd_present = qmd?(detector: qmd_detector)
105
+ enola_present = enola_detector ? !!enola_detector.call : enola?(cwd: cwd)
67
106
  serena_present = serena_detector ? !!serena_detector.call : serena?(cwd: cwd)
68
107
 
69
- if qmd_present && serena_present
70
- "QMD and Serena are available: #{QMD_OBLIGATION}, and #{SERENA_OBLIGATION}."
108
+ nav_present = enola_present || serena_present
109
+ nav_name = enola_present ? "Enola" : "Serena"
110
+ nav_obligation = enola_present ? ENOLA_OBLIGATION : SERENA_OBLIGATION
111
+
112
+ if qmd_present && nav_present
113
+ "QMD and #{nav_name} are available: #{QMD_OBLIGATION}, and #{nav_obligation}."
71
114
  elsif qmd_present
72
115
  "QMD is available: #{QMD_OBLIGATION}."
73
- elsif serena_present
74
- "Serena is available: #{SERENA_OBLIGATION}."
116
+ elsif nav_present
117
+ "#{nav_name} is available: #{nav_obligation}."
75
118
  end
76
119
  end
77
120
  end
@@ -0,0 +1,113 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+
6
+ # ProjectValidator - the single source of truth for "is a project spawn
7
+ # complete?" (intent 190).
8
+ #
9
+ # A project can be registered in projects.yml, have a store, and still be
10
+ # missing the pieces a real project needs: project.yml, a root AGENTS.md.
11
+ # The intent-26 spawn shipped exactly that shape and was caught only by a
12
+ # much later, pull-only plastic-doctor sweep. This module lets
13
+ # plastic-project-creating verify a spawn BEFORE announcing it as done,
14
+ # mirroring how scripts/new-intent already runs IntentValidator before
15
+ # announcing a new intent (validate-intent).
16
+ #
17
+ # Pure and dependency-injected: validate accepts an injectable plastic_home,
18
+ # uses no eval, performs no file writes, no global-constant injection.
19
+ # scripts/doctor.rb's check_project_store already covers 4 of these 6
20
+ # invariants (project_dir_exists, project_store_dir, project_index,
21
+ # project_yml_exists) plus cross_references, advisorially and pull-only;
22
+ # doctor adopting this module is a named follow-up, not part of this intent
23
+ # (D8). Invariant 4 (project-root AGENTS.md) has no existing check anywhere.
24
+ module ProjectValidator
25
+ module_function
26
+
27
+ def validate(slug, plastic_home: File.join(Dir.home, ".plastic"))
28
+ missing = []
29
+ errors = []
30
+
31
+ entry = registration_for(slug, plastic_home)
32
+ unless entry
33
+ missing << "projects.yml registration"
34
+ errors << "project '#{slug}' is not registered in projects.yml with a 'path' key"
35
+ return { ok: false, missing: missing, errors: errors }
36
+ end
37
+
38
+ project_path = entry["path"].to_s
39
+
40
+ # Invariant 2: registered project directory exists on disk.
41
+ unless File.directory?(project_path)
42
+ missing << "project directory"
43
+ errors << "registered project directory does not exist: #{project_path}"
44
+ end
45
+
46
+ project_dir = File.join(plastic_home, "projects", slug)
47
+
48
+ # Invariant 3: project.yml exists AND parses as YAML.
49
+ project_yml_path = File.join(project_dir, "project.yml")
50
+ if File.exist?(project_yml_path)
51
+ parsed = begin
52
+ YAML.safe_load(File.read(project_yml_path))
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ unless parsed.is_a?(Hash)
57
+ missing << "project.yml (valid YAML)"
58
+ errors << "project.yml exists at #{project_yml_path} but does not parse as YAML"
59
+ end
60
+ else
61
+ missing << "project.yml"
62
+ errors << "project.yml missing at #{project_yml_path}"
63
+ end
64
+
65
+ # Invariant 4: project-root AGENTS.md (the registered path, NOT
66
+ # ~/.plastic/projects/{slug}/). This is the intent-26 spawn's gap,
67
+ # uncaught by doctor.rb today.
68
+ agents_md_path = File.join(project_path, "AGENTS.md")
69
+ unless File.exist?(agents_md_path)
70
+ missing << "AGENTS.md (project root)"
71
+ errors << "AGENTS.md missing at project root: #{agents_md_path}"
72
+ end
73
+
74
+ # Invariant 5: store/ exists.
75
+ store_dir = File.join(project_dir, "store")
76
+ unless File.directory?(store_dir)
77
+ missing << "store/"
78
+ errors << "store directory missing: #{store_dir}"
79
+ end
80
+
81
+ # Invariant 6: INDEX.md exists.
82
+ index_md_path = File.join(project_dir, "INDEX.md")
83
+ unless File.exist?(index_md_path)
84
+ missing << "INDEX.md"
85
+ errors << "INDEX.md missing: #{index_md_path}"
86
+ end
87
+
88
+ { ok: missing.empty?, missing: missing, errors: errors }
89
+ end
90
+
91
+ # Invariant 1: registered in projects.yml with a 'path'. Returns the
92
+ # project's entry Hash, or nil when unregistered or the entry has no path.
93
+ def registration_for(slug, plastic_home)
94
+ projects = load_projects(plastic_home)
95
+ entry = projects[slug]
96
+ entry.is_a?(Hash) && entry["path"] ? entry : nil
97
+ end
98
+
99
+ # Parse projects.yml -> the `projects` Hash, or {} on any error/absence.
100
+ # Mirrors StoreProvisioning.load_projects.
101
+ def load_projects(plastic_home)
102
+ path = File.join(plastic_home, "projects.yml")
103
+ return {} unless File.exist?(path)
104
+
105
+ data = begin
106
+ YAML.safe_load(File.read(path)) || {}
107
+ rescue StandardError
108
+ {}
109
+ end
110
+ projects = data.is_a?(Hash) ? data["projects"] : nil
111
+ projects.is_a?(Hash) ? projects : {}
112
+ end
113
+ end
@@ -4,14 +4,15 @@
4
4
  require_relative "qmd_sync"
5
5
  require_relative "power_tools"
6
6
 
7
- # QmdHook decision logic for the power-tools UserPromptSubmit hook (intents 66,
7
+ # QmdHook - decision logic for the power-tools UserPromptSubmit hook (intents 66,
8
8
  # 66b). Pure and dependency-injected: returns the additionalContext string to
9
9
  # emit, or nil to emit nothing. The executable hook wires real deps and prints;
10
10
  # this is unit-tested with a fake runner/detector (no real qmd, no network).
11
11
  #
12
12
  # When qmd is present it still injects scored qmd hits (intent 66), then appends
13
- # the PowerTools mandate (a MUST obligation per present tool: qmd for finding
14
- # intents, serena for code navigation) instead of the old soft reminder.
13
+ # the PowerTools mandate (a recommendation per present tool: qmd for finding
14
+ # intents, Enola-first for code navigation, falling back to Serena; intent 187
15
+ # added the enola_detector alongside the pre-existing serena_detector).
15
16
  module QmdHook
16
17
  module_function
17
18
 
@@ -19,11 +20,13 @@ module QmdHook
19
20
 
20
21
  def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
21
22
  detector: QmdSync.method(:detect), limit: 3, min_score: 0.5,
22
- serena_detector: nil)
23
+ serena_detector: nil, enola_detector: nil)
23
24
  serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
25
+ enola_detector ||= -> { PowerTools.enola?(cwd: cwd) }
24
26
  qmd_present = !!detector.call
25
27
  serena_present = !!serena_detector.call
26
- return nil unless qmd_present || serena_present
28
+ enola_present = !!enola_detector.call
29
+ return nil unless qmd_present || serena_present || enola_present
27
30
 
28
31
  p = prompt.to_s.strip
29
32
  # The hit SEARCH is the only expensive step and the only one gated by prompt
@@ -37,19 +40,20 @@ module QmdHook
37
40
  hits = QmdSync.search(p, collections: collections, limit: limit,
38
41
  min_score: min_score, runner: runner, detector: detector)
39
42
  if hits.any?
40
- parts << "Related / prior Plastic intents (qmd BM25, includes completed) " \
43
+ parts << "Related / prior Plastic intents (qmd BM25, includes completed) - " \
41
44
  "check before treating this as new work:"
42
45
  hits.each do |h|
43
46
  loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
44
47
  pct = (h[:score] * 100).round
45
- parts << "- [#{pct}%] #{loc} #{h[:title]}"
48
+ parts << "- [#{pct}%] #{loc} - #{h[:title]}"
46
49
  end
47
50
  parts << ""
48
51
  end
49
52
  end
50
53
 
51
54
  mandate = PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
52
- serena_detector: -> { serena_present })
55
+ serena_detector: -> { serena_present },
56
+ enola_detector: -> { enola_present })
53
57
  parts << mandate if mandate
54
58
  return nil if parts.empty?
55
59
  parts.join("\n")
@@ -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