@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
@@ -37,9 +37,18 @@ module IntentValidator
37
37
  # (for example "14", "14a", "4a1"). Mirrors scripts/folgezettel-id.
38
38
  ID_PATTERN = /\A([a-z0-9-]+:)?\d+[a-z0-9]*\z/
39
39
 
40
- # True iff `value` is a String matching the Folgezettel id form.
41
- def valid_id?(value)
42
- value.to_s.match?(ID_PATTERN)
40
+ # True iff `value` is a String matching the Folgezettel id form. When `known_stores` is
41
+ # given (an Array of store slugs, e.g. from StoreDiscovery.known_slugs), a cross-store
42
+ # prefix must also name a store in that set; a bare id (no prefix) is unaffected. When
43
+ # `known_stores` is nil (the default), only the shape is checked, so every existing
44
+ # caller keeps working unchanged (intent 189 D3).
45
+ def valid_id?(value, known_stores: nil)
46
+ s = value.to_s
47
+ return false unless s.match?(ID_PATTERN)
48
+ return true if known_stores.nil?
49
+
50
+ prefix = s[/\A([a-z0-9-]+):/, 1]
51
+ prefix.nil? || known_stores.include?(prefix)
43
52
  end
44
53
 
45
54
  # Read a file's YAML frontmatter, returning the parsed Hash (or {} when the
@@ -93,8 +102,10 @@ module IntentValidator
93
102
  end
94
103
 
95
104
  # PURE: given a parsed frontmatter Hash (or nil), return
96
- # { ok: Boolean, missing: [field names], errors: [human strings] }.
97
- def validate_frontmatter(fm)
105
+ # { ok: Boolean, missing: [field names], errors: [human strings] }. `known_stores`
106
+ # (optional, an Array of store slugs) is forwarded to valid_id? for each array-field
107
+ # element; when nil, only id shape is checked (unchanged existing behavior).
108
+ def validate_frontmatter(fm, known_stores: nil)
98
109
  unless fm.is_a?(Hash)
99
110
  return { ok: false, missing: REQUIRED_FIELDS.dup, errors: ["no frontmatter found"] }
100
111
  end
@@ -112,20 +123,37 @@ module IntentValidator
112
123
  end
113
124
 
114
125
  value.each do |element|
115
- errors << "#{key} has invalid id: #{element.inspect}" unless valid_id?(element)
126
+ next if valid_id?(element, known_stores: known_stores)
127
+
128
+ errors << id_error(key, element, known_stores)
116
129
  end
117
130
  end
118
131
 
119
132
  { ok: missing.empty? && errors.empty?, missing: missing, errors: errors }
120
133
  end
121
134
 
135
+ # Build the rejection message for one bad id. Distinguishes a shape failure (never a
136
+ # valid Folgezettel form at all) from a known-store rejection (right shape, but the
137
+ # store prefix names a store that does not exist), so an agent can tell "typo'd id" apart
138
+ # from "made up a store" (intent 189 D3).
139
+ def id_error(key, element, known_stores)
140
+ s = element.to_s
141
+ prefix = known_stores && s.match?(ID_PATTERN) ? s[/\A([a-z0-9-]+):/, 1] : nil
142
+ if prefix
143
+ "#{key} has invalid id: #{element.inspect} (store #{prefix.inspect} is not a known " \
144
+ "store; known stores: #{known_stores.sort.join(", ")})"
145
+ else
146
+ "#{key} has invalid id: #{element.inspect}"
147
+ end
148
+ end
149
+
122
150
  # PURE: combine the frontmatter result with section-structure findings for a
123
151
  # content STRING. Returns the frontmatter result hash extended with
124
152
  # :section_missing, :section_unknown, and folded section errors; :ok is the AND
125
153
  # of frontmatter and sections. Lets the create gate validate proposed content
126
154
  # (no file on disk) with the same definition as the CLI and doctor.
127
- def validate_content(content)
128
- fm_result = validate_frontmatter(parse_frontmatter_text(content))
155
+ def validate_content(content, known_stores: nil)
156
+ fm_result = validate_frontmatter(parse_frontmatter_text(content), known_stores: known_stores)
129
157
  sections = validate_sections(body_of(content))
130
158
  merge_sections(fm_result, sections)
131
159
  end
@@ -148,10 +176,10 @@ module IntentValidator
148
176
  # Resolve an intent directory's primary md file and validate its frontmatter
149
177
  # AND its sanctioned section structure. `plastic_home` is accepted for
150
178
  # house-style parity (injectable) even though validation reads the dir directly.
151
- def validate(intent_dir, plastic_home: File.join(Dir.home, ".plastic"))
179
+ def validate(intent_dir, plastic_home: File.join(Dir.home, ".plastic"), known_stores: nil)
152
180
  md_path = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
153
181
  content = File.exist?(md_path) ? File.read(md_path) : nil
154
- validate_content(content)
182
+ validate_content(content, known_stores: known_stores)
155
183
  end
156
184
 
157
185
  # PURE: cross-intent graph-shape invariants (intent 68). These need visibility
@@ -0,0 +1,140 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_validator"
5
+ require_relative "links_section"
6
+ require_relative "links_projection"
7
+ require_relative "store_discovery"
8
+ require_relative "graph_rebuild"
9
+
10
+ # LinksGate, the write-time belt for the PLASTIC.md `## Links` contract
11
+ # (intent 192). Pure decision logic plus a small store-scanning glue (mirrors
12
+ # the glue ProjectLinks and Doctor each already carry independently for
13
+ # themselves; the CALCULATION is shared via LinksSection/LinksProjection so
14
+ # gate, projector, and doctor can never disagree by construction, even though
15
+ # each IO shell still does its own discovery, exactly as project-links and
16
+ # doctor already do today).
17
+ #
18
+ # #decision is the single entry point the PreToolUse hook
19
+ # (scripts/hook-links-gate) calls: given a file path and its BEFORE/AFTER
20
+ # content (BEFORE = on-disk, AFTER = the proposed Write/Edit result), it
21
+ # returns nil (allow) or a deny message (String). It only ever judges the
22
+ # REAL, fence-aware `## Links` section (LinksSection.extract_section); an
23
+ # edit that leaves that section untouched is always allowed, cheaply, with no
24
+ # store scan at all.
25
+ module LinksGate
26
+ module_function
27
+
28
+ DENY_MESSAGE =
29
+ "PLASTIC LINKS GATE - a ## Links line must come from the frontmatter " \
30
+ "sources/chain graph, never be hand-typed. This edit changes the ## Links " \
31
+ "section to something other than its frontmatter projection. Add the edge " \
32
+ "to sources or chain in frontmatter instead, then run scripts/project-links " \
33
+ "to regenerate ## Links.".freeze
34
+
35
+ # True iff `path` is an intent file inside its own equally-named store
36
+ # directory (store/<id>--<slug>/<id>--<slug>.md). Mirrors the create-gate
37
+ # path matcher (intent 60b) so the two gates agree on what "an intent file" is.
38
+ def intent_file?(path)
39
+ return false if path.to_s.strip.empty?
40
+
41
+ abs = File.expand_path(path)
42
+ dir = File.dirname(abs)
43
+ dir.match?(%r{/store/[^/]+--[^/]+\z}) && File.basename(abs) == "#{File.basename(dir)}.md"
44
+ end
45
+
46
+ # Decide whether to deny a Write/Edit whose proposed result is
47
+ # `after_content` (the on-disk content before the edit is `before_content`,
48
+ # "" when the file does not yet exist). Returns nil (allow, including every
49
+ # "cannot judge" case) or DENY_MESSAGE.
50
+ def decision(file_path:, before_content:, after_content:, plastic_home:)
51
+ return nil unless intent_file?(file_path)
52
+
53
+ before_links = safe_extract(before_content)
54
+ after_links = safe_extract(after_content)
55
+ return nil if before_links.nil? || after_links.nil? # ambiguous ## Links; cannot judge
56
+ return nil if before_links == after_links # this edit does not touch ## Links at all
57
+
58
+ fm = IntentValidator.parse_frontmatter_text(after_content.to_s)
59
+ return nil unless fm.is_a?(Hash) # no parseable frontmatter; cannot judge
60
+
61
+ referer_store = store_key_for(file_path, plastic_home)
62
+ return nil unless referer_store # not under any discovered store; cannot judge
63
+
64
+ ctx = build_context(plastic_home)
65
+ resolve = ->(ref) do
66
+ LinksProjection.resolve_ref_projection(
67
+ ref, referer_store: referer_store, relocation_map: ctx[:relocation_map],
68
+ store_index: ctx[:store_index], node_index: ctx[:node_index]
69
+ )
70
+ end
71
+
72
+ expected =
73
+ begin
74
+ LinksProjection.section(sources: Array(fm["sources"]), chain: Array(fm["chain"]),
75
+ resolve: resolve)
76
+ rescue LinksProjection::UnresolvedRef
77
+ return nil # a pre-existing dead frontmatter ref is a doctor finding, not this gate's job
78
+ end
79
+
80
+ after_links == expected ? nil : DENY_MESSAGE
81
+ end
82
+
83
+ # Fence-aware real-section extract, tolerant of an ambiguous file (returns
84
+ # nil rather than raising, so #decision can fail open on it).
85
+ def safe_extract(content)
86
+ LinksSection.extract_section(IntentValidator.body_of(content.to_s))
87
+ rescue LinksSection::AmbiguousLinks
88
+ nil
89
+ end
90
+
91
+ # Which discovered store (by store_index/node_index key) `file_path` lives
92
+ # under, or nil when it is not inside any store this plastic_home discovers.
93
+ def store_key_for(file_path, plastic_home)
94
+ abs = File.expand_path(file_path)
95
+ store_dir = File.dirname(File.dirname(abs)) # .../<store>/<id>--<slug>/<file>.md
96
+ StoreDiscovery.discover(plastic_home)[:stores]
97
+ .find { |s| File.expand_path(s[:store]) == store_dir }
98
+ &.fetch(:key)
99
+ end
100
+
101
+ # Build the store_index/node_index/relocation_map resolver context spanning
102
+ # every discovered store, the same shape ProjectLinks#run and Doctor each
103
+ # build for themselves. Re-scanned per call (no caching): this only runs on
104
+ # the rare edit that actually changes ## Links, so the cost is paid where it
105
+ # matters, not on every Edit/Write.
106
+ def build_context(plastic_home)
107
+ discovery = StoreDiscovery.discover(plastic_home)
108
+ store_index = {}
109
+ node_index = {}
110
+ index_texts = {}
111
+
112
+ discovery[:stores].each do |s|
113
+ nodes = load_nodes(s[:store])
114
+ store_index[s[:key]] = nodes.keys
115
+ node_index[s[:key]] = nodes.transform_values { |v| { basename: v[:basename], label: v[:label] } }
116
+ index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
117
+ end
118
+
119
+ { store_index: store_index, node_index: node_index,
120
+ relocation_map: GraphRebuild.build_relocation_map(index_texts) }
121
+ end
122
+
123
+ # { id => { basename:, label: } } for one store directory.
124
+ def load_nodes(store_dir)
125
+ nodes = {}
126
+ Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
127
+ dir = File.join(store_dir, entry)
128
+ next unless File.directory?(dir)
129
+
130
+ md = File.join(dir, "#{entry}.md")
131
+ next unless File.exist?(md)
132
+
133
+ fm = IntentValidator.parse_frontmatter(md)
134
+ next unless fm.is_a?(Hash) && fm["id"]
135
+
136
+ nodes[fm["id"].to_s] = { basename: entry, label: fm["intent"].to_s.strip }
137
+ end
138
+ nodes
139
+ end
140
+ end
@@ -27,14 +27,37 @@ module LinksProjection
27
27
  HEADING = "## Links"
28
28
  EMPTY_COMMENT = "<!-- No sources or chain; this intent has no graph edges to project. -->"
29
29
 
30
+ # A single rendered entry line, e.g. `- [[10--demo|Some intent]]` or
31
+ # `- [[knowdb:1--demo|Some intent]]`. The inverse of the line #entry renders.
32
+ ENTRY_LINE_RE = /\A- \[\[([^|\]]+)\|(.*)\]\]\z/
33
+
30
34
  # Raised when a sources/chain ref resolves to no intent. Carries the offending
31
- # ref so the IO shell can report it per-intent and skip the write.
35
+ # ref, plus, when the resolver supplies one, the GraphRebuild status behind the
36
+ # miss (`:dead` or `:unknown_store`), so a rescuer can tell "genuinely gone" apart
37
+ # from "store not discovered this run, left untouched" instead of one generic
38
+ # failure. `reason` is nil when the resolver has no status to report (a live
39
+ # store/id whose node_index lookup still misses).
32
40
  class UnresolvedRef < StandardError
33
- attr_reader :ref
41
+ attr_reader :ref, :reason
34
42
 
35
- def initialize(ref)
43
+ def initialize(ref, reason: nil, store: nil)
36
44
  @ref = ref
37
- super("unresolved sources/chain ref: #{ref.inspect}")
45
+ @reason = reason
46
+ super("#{message_for(reason, store)}: #{ref.inspect}")
47
+ end
48
+
49
+ private
50
+
51
+ def message_for(reason, store)
52
+ case reason
53
+ when :dead
54
+ "unresolved sources/chain ref (dead, resolves to no intent)"
55
+ when :unknown_store
56
+ "unresolved sources/chain ref (unknown store #{store.inspect}, not discovered " \
57
+ "this run; left untouched, verify store discovery)"
58
+ else
59
+ "unresolved sources/chain ref"
60
+ end
38
61
  end
39
62
  end
40
63
 
@@ -76,6 +99,33 @@ module LinksProjection
76
99
  "#{HEADING}\n#{EMPTY_COMMENT}\n"
77
100
  end
78
101
 
102
+ # PURE. Parse a rendered or extracted `## Links` section's entry lines back into
103
+ # [{target:, label:}], in the order they appear. Ignores the heading line and the
104
+ # empty-state comment (and any other line that is not a `- [[target|label]]`
105
+ # line). The structural inverse of #entry's line shape. Used by callers
106
+ # (project-links) that need to compare or merge an OLD section's entries
107
+ # against a freshly-computed canonical one, one level below #section's own
108
+ # resolve-and-render.
109
+ def parse_entries(text)
110
+ text.to_s.each_line.filter_map do |line|
111
+ m = line.chomp.match(ENTRY_LINE_RE)
112
+ m && { target: m[1], label: m[2] }
113
+ end
114
+ end
115
+
116
+ # PURE. Render a final `## Links` block from an ALREADY-RESOLVED, ALREADY
117
+ # ORDERED list of {target:, label:} entries (no resolve callable; the caller
118
+ # has already done resolution/merging, e.g. project-links merging canonical
119
+ # entries with preserved orphan entries). Falls back to the empty-state
120
+ # comment when `entries` is empty. Shares the heading/line format with
121
+ # #section so the two can never render a different shape for the same list.
122
+ def render_entries(entries)
123
+ list = Array(entries)
124
+ return empty_section if list.empty?
125
+
126
+ (["#{HEADING}\n"] + list.map { |e| "- [[#{e[:target]}|#{e[:label]}]]\n" }).join
127
+ end
128
+
79
129
  # Resolve `ref`, render its entry, and append it to `rendered` UNLESS its resolved
80
130
  # target was already emitted (dedup by resolved target, first-seen wins so sources
81
131
  # precede chain). Mutates `seen` and `rendered`. Raises UnresolvedRef on a miss.
@@ -88,13 +138,18 @@ module LinksProjection
88
138
  end
89
139
 
90
140
  # PURE. Resolve one ref to [target, label]. Raises UnresolvedRef when the
91
- # resolver returns nothing usable.
141
+ # resolver returns nothing usable, carrying whatever :reason/:store the
142
+ # resolver supplied (see #resolve_ref_projection) so the miss stays
143
+ # distinguishable at the IO shell.
92
144
  def resolve_entry(ref, resolve)
93
145
  resolved = resolve.call(ref)
94
- target = resolved.is_a?(Hash) ? resolved[:target] || resolved["target"] : nil
95
- raise UnresolvedRef, ref if target.nil? || target.to_s.strip.empty?
146
+ hash = resolved.is_a?(Hash) ? resolved : {}
147
+ target = hash[:target] || hash["target"]
148
+ if target.nil? || target.to_s.strip.empty?
149
+ raise UnresolvedRef.new(ref, reason: hash[:reason], store: hash[:store])
150
+ end
96
151
 
97
- label = (resolved[:label] || resolved["label"]).to_s.strip
152
+ label = (hash[:label] || hash["label"]).to_s.strip
98
153
  [target.to_s, label]
99
154
  end
100
155
 
@@ -117,8 +172,10 @@ module LinksProjection
117
172
  # node_index — { store_key => { id => { basename:, label: } } } (spans all stores)
118
173
  #
119
174
  # Returns { target:, label: } (target is `<id>--<slug>` for a same-store id, or
120
- # `<slug>:<id>--<slug>` for a cross-store one), or nil when the ref resolves to no
121
- # live intent (which makes #section / #entry raise UnresolvedRef).
175
+ # `<slug>:<id>--<slug>` for a cross-store one). On a miss, returns { reason: :dead }
176
+ # or { reason: :unknown_store, store: } instead of a bare nil, so #resolve_entry can
177
+ # raise UnresolvedRef with a distinguishable message (#section / #entry still raise
178
+ # either way; only the message differs).
122
179
  #
123
180
  # Uses GraphRebuild.resolve_ref so a relocation always wins over a coincidentally
124
181
  # reused id (the `global:24` impostor hazard), exactly as the frontmatter rebuild
@@ -142,8 +199,10 @@ module LinksProjection
142
199
  return nil if node.nil?
143
200
 
144
201
  { target: "#{slug}:#{node[:basename]}", label: node[:label] }
145
- else # :dead
146
- nil
202
+ when :dead
203
+ { reason: :dead }
204
+ when :unknown_store
205
+ { reason: :unknown_store, store: res[:store] }
147
206
  end
148
207
  end
149
208
 
@@ -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")