@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.
- package/PLASTIC-reference.md +8 -6
- package/PLASTIC.md +68 -6
- package/README.md +5 -0
- package/agents/plastic-advisor.md +56 -0
- package/agents/plastic-enforcer.md +9 -1
- package/agents/plastic-faux-advisor.md +174 -0
- package/agents/plastic-future-intent-researcher.md +1 -0
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/hooks/statusline +1 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +164 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/install.rb +8 -0
- package/scripts/lib/agent_models.rb +36 -9
- package/scripts/lib/bridge.rb +29 -1
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +2 -1
- package/scripts/lib/installer_core.rb +130 -23
- package/scripts/lib/intent_validator.rb +38 -10
- package/scripts/lib/links_gate.rb +140 -0
- package/scripts/lib/links_projection.rb +71 -12
- package/scripts/lib/power_tools.rb +57 -14
- package/scripts/lib/project_validator.rb +113 -0
- package/scripts/lib/qmd_hook.rb +12 -8
- package/scripts/lib/restore_intent_v1.rb +154 -0
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +38 -10
- package/scripts/lib/store_discovery.rb +77 -0
- package/scripts/lib/store_provisioning.rb +21 -12
- package/scripts/new-intent +10 -12
- package/scripts/project-links +132 -35
- package/scripts/provision-project-store +18 -5
- package/scripts/read-config +1 -0
- package/scripts/rebuild-graph +42 -17
- package/scripts/restore-intent-v1 +288 -0
- package/scripts/roadmap-next +9 -2
- package/scripts/roadmap-savepoint +9 -1
- package/scripts/update.rb +50 -1
- package/scripts/validate-intent +3 -1
- package/scripts/validate-project +53 -0
- package/scripts/write-config +105 -0
- package/skills/agent-advisor/SKILL.md +92 -0
- package/skills/agent-advisor/references/advisor-protocol.md +245 -0
- package/skills/auto/SKILL.md +26 -12
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/install/SKILL.md +30 -2
- package/skills/intent-creating/SKILL.md +5 -0
- package/skills/intent-ending/SKILL.md +49 -36
- package/skills/project-creating/SKILL.md +29 -1
- package/skills/releasing/SKILL.md +37 -19
- package/skills/roadmap/SKILL.md +9 -7
- package/skills/roadmap/references/file-format.md +14 -10
- package/skills/roadmap/references/operations.md +22 -18
- package/skills/roadmap-continuing/SKILL.md +5 -5
- package/skills/roadmap-continuing/evals/evals.json +3 -3
- package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
- package/skills/update/SKILL.md +34 -4
- package/templates/config.yml +31 -6
- 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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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 = (
|
|
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)
|
|
121
|
-
#
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
7
|
-
# (intent 66b; demoted to recommendations in intent 108, D8
|
|
8
|
-
# deterministic detection of each tool and builds a
|
|
9
|
-
# whichever tools are present, so the agent is
|
|
10
|
-
# them: QMD for finding intents,
|
|
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 /
|
|
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
|
-
#
|
|
63
|
-
# embedded newline); one present returns that tool's own
|
|
64
|
-
# returns nil.
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
74
|
-
"
|
|
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
|
package/scripts/lib/qmd_hook.rb
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
require_relative "qmd_sync"
|
|
5
5
|
require_relative "power_tools"
|
|
6
6
|
|
|
7
|
-
# QmdHook
|
|
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
|
|
14
|
-
# intents,
|
|
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
|
-
|
|
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}
|
|
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")
|