@zalom/plastic 1.0.0-beta.1 → 1.0.0-beta.11
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.md +21 -5
- package/agents/plastic-brainstorming.md +9 -1
- package/agents/plastic-executor.md +10 -0
- package/agents/plastic-intent-curator.md +7 -5
- package/agents/plastic-planner.md +11 -1
- package/agents/plastic-spec-specialist.md +9 -1
- package/hooks/statusline +150 -41
- package/package.json +1 -1
- package/scripts/agent-report +142 -0
- package/scripts/dashboard.rb +5 -3
- package/scripts/doctor.rb +243 -0
- package/scripts/lib/bridge.rb +72 -24
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +5 -0
- package/scripts/lib/intent_validator.rb +79 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/power_tools.rb +76 -0
- package/scripts/lib/qmd_hook.rb +38 -25
- package/scripts/lib/qmd_sync.rb +21 -0
- package/scripts/new-intent +172 -22
- package/scripts/project-links +287 -0
- package/scripts/qmd-sync +50 -3
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +18 -1
- package/skills/auto/SKILL.md +24 -9
- package/skills/auto/evals/evals.json +48 -0
- package/skills/auto/references/agent-architecture.md +27 -4
- package/skills/auto/references/agent-report-contract.md +86 -0
- package/skills/brainstorming/SKILL.md +1 -0
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/continuing/SKILL.md +8 -1
- package/skills/continuing/evals/evals.json +9 -0
- package/skills/creating-intent/SKILL.md +28 -8
- package/skills/creating-intent/evals/evals.json +72 -0
- package/skills/creating-intent/references/lifecycle.md +12 -4
- package/skills/dashboard/SKILL.md +5 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/executing-plan/SKILL.md +2 -2
- package/skills/intent-curator/SKILL.md +3 -1
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +17 -6
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +15 -3
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +7 -2
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# project-links — project each intent's corrected sources/chain graph into its
|
|
6
|
+
# canonical I5 `## Links` section, store-wide across the global, plastic, and
|
|
7
|
+
# knowdb stores (intent 72). Deterministic and idempotent: a second run over a
|
|
8
|
+
# projected store changes zero files. Touches ONLY the `## Links` section of each
|
|
9
|
+
# intent file; frontmatter and every other section stay byte-identical.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# project-links [--plastic-home PATH] [--dry-run] [--audit-path PATH]
|
|
13
|
+
#
|
|
14
|
+
# Pure-Ruby (no bash). The pure logic lives in lib/links_projection.rb and
|
|
15
|
+
# lib/links_section.rb; this shell does only discovery, IO, and reporting. It reads
|
|
16
|
+
# frontmatter (it NEVER writes frontmatter, that is intent 49's domain) and the
|
|
17
|
+
# on-disk `id--slug` directory basenames to build the cross-store resolver.
|
|
18
|
+
# Never pushes ~/.plastic (no git ops here).
|
|
19
|
+
|
|
20
|
+
require "time"
|
|
21
|
+
require "fileutils"
|
|
22
|
+
|
|
23
|
+
require_relative "lib/intent_validator"
|
|
24
|
+
require_relative "lib/graph_rebuild"
|
|
25
|
+
require_relative "lib/links_projection"
|
|
26
|
+
require_relative "lib/links_section"
|
|
27
|
+
|
|
28
|
+
class ProjectLinks
|
|
29
|
+
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
30
|
+
|
|
31
|
+
# The 72 intent dir audit destination (relative to plastic_home).
|
|
32
|
+
DEFAULT_AUDIT_REL =
|
|
33
|
+
"projects/plastic/store/72--links-graph-projection/resources/audit--links-projection.md"
|
|
34
|
+
|
|
35
|
+
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
|
|
36
|
+
@plastic_home = plastic_home
|
|
37
|
+
@dry_run = dry_run
|
|
38
|
+
|
|
39
|
+
# A dry run must NOT stomp the canonical audit (humans run --dry-run to review
|
|
40
|
+
# the plan). With no explicit --audit-path, a dry run writes a `.dry-run.md`
|
|
41
|
+
# sibling, leaving the canonical real-run audit untouched. An explicit
|
|
42
|
+
# --audit-path is always honored verbatim (tests inject it). Mirrors
|
|
43
|
+
# RebuildGraph's discipline exactly.
|
|
44
|
+
canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
|
|
45
|
+
@audit_path =
|
|
46
|
+
if audit_path
|
|
47
|
+
audit_path
|
|
48
|
+
elsif dry_run
|
|
49
|
+
canonical.sub(/\.md\z/, ".dry-run.md")
|
|
50
|
+
else
|
|
51
|
+
canonical
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_reader :plastic_home, :dry_run, :audit_path
|
|
56
|
+
|
|
57
|
+
# The three in-scope stores, each as { key:, root:, store:, index: }.
|
|
58
|
+
def stores
|
|
59
|
+
list = []
|
|
60
|
+
global_store = File.join(plastic_home, "store")
|
|
61
|
+
if File.directory?(global_store)
|
|
62
|
+
list << { key: "global", root: plastic_home, store: global_store,
|
|
63
|
+
index: File.join(plastic_home, "INDEX.md") }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
%w[plastic knowdb].each do |slug|
|
|
67
|
+
root = File.join(plastic_home, "projects", slug)
|
|
68
|
+
store = File.join(root, "store")
|
|
69
|
+
next unless File.directory?(store)
|
|
70
|
+
|
|
71
|
+
list << { key: "project:#{slug}", root: root, store: store,
|
|
72
|
+
index: File.join(root, "INDEX.md") }
|
|
73
|
+
end
|
|
74
|
+
list
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# { id => { sources:, chain:, basename:, label:, path: } } for one store.
|
|
78
|
+
def load_nodes(store_dir)
|
|
79
|
+
nodes = {}
|
|
80
|
+
Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
81
|
+
dir = File.join(store_dir, entry)
|
|
82
|
+
next unless File.directory?(dir)
|
|
83
|
+
|
|
84
|
+
md = File.join(dir, "#{entry}.md")
|
|
85
|
+
next unless File.exist?(md)
|
|
86
|
+
|
|
87
|
+
fm = IntentValidator.parse_frontmatter(md)
|
|
88
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
89
|
+
|
|
90
|
+
nodes[fm["id"].to_s] = {
|
|
91
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
92
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
93
|
+
basename: entry, # the on-disk `id--slug` directory name
|
|
94
|
+
label: fm["intent"].to_s.strip,
|
|
95
|
+
path: md,
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
nodes
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def run
|
|
102
|
+
store_list = stores
|
|
103
|
+
nodes_by_store = {}
|
|
104
|
+
index_texts = {}
|
|
105
|
+
store_index = {}
|
|
106
|
+
node_index = {}
|
|
107
|
+
|
|
108
|
+
store_list.each do |s|
|
|
109
|
+
key = s[:key]
|
|
110
|
+
nodes_by_store[key] = load_nodes(s[:store])
|
|
111
|
+
index_texts[key] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
|
|
112
|
+
store_index[key] = nodes_by_store[key].keys
|
|
113
|
+
node_index[key] = nodes_by_store[key].transform_values do |v|
|
|
114
|
+
{ basename: v[:basename], label: v[:label] }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
relocation_map = GraphRebuild.build_relocation_map(index_texts)
|
|
119
|
+
|
|
120
|
+
results = {}
|
|
121
|
+
store_list.each do |s|
|
|
122
|
+
key = s[:key]
|
|
123
|
+
results[key] = project_store(
|
|
124
|
+
key, nodes_by_store[key],
|
|
125
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
emit_audit(store_list, results)
|
|
130
|
+
results
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Project every intent in ONE store. Returns
|
|
134
|
+
# { entries: [ {id:, status:, before:, after:, error:} ], counts: {...} }.
|
|
135
|
+
def project_store(referer_store, nodes, relocation_map:, store_index:, node_index:)
|
|
136
|
+
entries = []
|
|
137
|
+
nodes.each do |id, node|
|
|
138
|
+
resolve = ->(ref) do
|
|
139
|
+
LinksProjection.resolve_ref_projection(
|
|
140
|
+
ref, referer_store: referer_store,
|
|
141
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
content = File.read(node[:path])
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
had_links = LinksSection.links_heading?(IntentValidator.body_of(content))
|
|
149
|
+
section_text = LinksProjection.section(
|
|
150
|
+
sources: node[:sources], chain: node[:chain], resolve: resolve
|
|
151
|
+
)
|
|
152
|
+
updated = LinksSection.rewrite(content, section_text)
|
|
153
|
+
rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
|
|
154
|
+
entries << { id: id, status: :failed, error: e.message }
|
|
155
|
+
next
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if updated == content
|
|
159
|
+
entries << { id: id, status: :unchanged }
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
status = had_links ? :regenerated : :added
|
|
164
|
+
File.write(node[:path], updated) unless dry_run
|
|
165
|
+
entries << { id: id, status: status,
|
|
166
|
+
before: extract_links(content), after: section_text }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
counts = entries.each_with_object(Hash.new(0)) { |e, h| h[e[:status]] += 1 }
|
|
170
|
+
{ entries: entries, counts: counts }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Pull the current REAL `## Links` section text (fence-aware) from a file's
|
|
174
|
+
# content, for the audit sample. Returns "" when absent. Delegates to the shared
|
|
175
|
+
# LinksSection.extract_section so the audit, the rewriter, and the doctor check
|
|
176
|
+
# all agree on the section location (and never match a heading inside an example
|
|
177
|
+
# code fence).
|
|
178
|
+
def extract_links(content)
|
|
179
|
+
LinksSection.extract_section(IntentValidator.body_of(content))
|
|
180
|
+
rescue LinksSection::AmbiguousLinks
|
|
181
|
+
"(ambiguous: multiple ## Links headings)"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Always write the audit (even in dry-run, so the human reviews the plan).
|
|
185
|
+
def emit_audit(store_list, results)
|
|
186
|
+
text = render_audit(store_list, results)
|
|
187
|
+
FileUtils.mkdir_p(File.dirname(audit_path))
|
|
188
|
+
File.write(audit_path, text)
|
|
189
|
+
text
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
STATUS_ORDER = %i[regenerated added unchanged failed].freeze
|
|
193
|
+
STATUS_LABELS = {
|
|
194
|
+
regenerated: "Regenerated (had a ## Links, content changed)",
|
|
195
|
+
added: "Added (no ## Links section, one inserted)",
|
|
196
|
+
unchanged: "Unchanged (already canonical)",
|
|
197
|
+
failed: "FAILED (resolver miss, NOT written)",
|
|
198
|
+
}.freeze
|
|
199
|
+
|
|
200
|
+
def render_audit(store_list, results)
|
|
201
|
+
lines = []
|
|
202
|
+
lines << "# Audit: store-wide ## Links projection (intent 72)"
|
|
203
|
+
lines << ""
|
|
204
|
+
lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
|
|
205
|
+
lines << ""
|
|
206
|
+
|
|
207
|
+
total = STATUS_ORDER.to_h do |st|
|
|
208
|
+
[st, store_list.sum { |s| results[s[:key]][:counts][st] }]
|
|
209
|
+
end
|
|
210
|
+
lines << "Totals across all stores: " \
|
|
211
|
+
"regenerated #{total[:regenerated]}, added #{total[:added]}, " \
|
|
212
|
+
"unchanged #{total[:unchanged]}, failed #{total[:failed]}."
|
|
213
|
+
lines << ""
|
|
214
|
+
|
|
215
|
+
store_list.each do |s|
|
|
216
|
+
key = s[:key]
|
|
217
|
+
res = results[key]
|
|
218
|
+
counts = res[:counts]
|
|
219
|
+
lines << "## #{key}"
|
|
220
|
+
lines << ""
|
|
221
|
+
lines << "Regenerated #{counts[:regenerated]}, added #{counts[:added]}, " \
|
|
222
|
+
"unchanged #{counts[:unchanged]}, failed #{counts[:failed]}."
|
|
223
|
+
lines << ""
|
|
224
|
+
|
|
225
|
+
failed = res[:entries].select { |e| e[:status] == :failed }
|
|
226
|
+
unless failed.empty?
|
|
227
|
+
lines << "### FAILED (#{failed.size})"
|
|
228
|
+
failed.each { |e| lines << "- #{e[:id]}: #{e[:error]}" }
|
|
229
|
+
lines << ""
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
sample = res[:entries].select { |e| %i[regenerated added].include?(e[:status]) }.first(5)
|
|
233
|
+
next if sample.empty?
|
|
234
|
+
|
|
235
|
+
lines << "### Sample before/after (first #{sample.size})"
|
|
236
|
+
sample.each do |e|
|
|
237
|
+
lines << "- #{e[:id]} (#{e[:status]}):"
|
|
238
|
+
lines << " - BEFORE:"
|
|
239
|
+
block_lines(e[:before]).each { |l| lines << " #{l}" }
|
|
240
|
+
lines << " - AFTER:"
|
|
241
|
+
block_lines(e[:after]).each { |l| lines << " #{l}" }
|
|
242
|
+
end
|
|
243
|
+
lines << ""
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
lines.join("\n") + "\n"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def block_lines(text)
|
|
250
|
+
s = text.to_s.strip
|
|
251
|
+
return ["(none)"] if s.empty?
|
|
252
|
+
|
|
253
|
+
s.lines.map(&:rstrip)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# True iff any intent failed (resolver miss).
|
|
257
|
+
def any_failed?(results)
|
|
258
|
+
results.values.any? { |r| r[:counts][:failed].to_i.positive? }
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if $PROGRAM_NAME == __FILE__
|
|
263
|
+
home = ProjectLinks::DEFAULT_HOME
|
|
264
|
+
dry = false
|
|
265
|
+
audit = nil
|
|
266
|
+
i = 0
|
|
267
|
+
while i < ARGV.length
|
|
268
|
+
case ARGV[i]
|
|
269
|
+
when "--plastic-home" then home = ARGV[i + 1]; i += 2
|
|
270
|
+
when "--dry-run" then dry = true; i += 1
|
|
271
|
+
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
272
|
+
else i += 1
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
tool = ProjectLinks.new(plastic_home: home, dry_run: dry, audit_path: audit)
|
|
277
|
+
results = tool.run
|
|
278
|
+
totals = ProjectLinks::STATUS_ORDER.to_h do |st|
|
|
279
|
+
[st, results.values.sum { |r| r[:counts][st] }]
|
|
280
|
+
end
|
|
281
|
+
puts "project-links #{dry ? "DRY RUN" : "applied"}: " \
|
|
282
|
+
"regenerated #{totals[:regenerated]}, added #{totals[:added]}, " \
|
|
283
|
+
"unchanged #{totals[:unchanged]}, failed #{totals[:failed]} " \
|
|
284
|
+
"across #{results.size} store(s)."
|
|
285
|
+
puts "Audit: #{tool.audit_path}"
|
|
286
|
+
exit 1 if tool.any_failed?(results)
|
|
287
|
+
end
|
package/scripts/qmd-sync
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
# qmd-sync register --store <dir> # register one store as a collection
|
|
14
14
|
# qmd-sync register --all # register the global store + all projects
|
|
15
15
|
# qmd-sync reindex --store <dir> # update + embed that store's collection
|
|
16
|
+
# qmd-sync reindex --store <dir> --async # same, detached/non-blocking
|
|
16
17
|
# qmd-sync status [--format json] # read-only status
|
|
18
|
+
# qmd-sync search "<terms>" [--store <dir>] [--limit N] [--min-score F]
|
|
19
|
+
# # ranked store search; scope by --store or CWD
|
|
17
20
|
#
|
|
18
21
|
# --home <path> overrides the Plastic home (default: ~/.plastic).
|
|
19
22
|
|
|
@@ -71,8 +74,13 @@ when "register"
|
|
|
71
74
|
when "reindex"
|
|
72
75
|
dir = opt(ARGV, "--store") or (warn("reindex: pass --store <dir>"); exit 2)
|
|
73
76
|
collection = QmdSync.collection_name(dir, plastic_home: home)
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
if ARGV.include?("--async")
|
|
78
|
+
QmdSync.reindex_async(collection: collection)
|
|
79
|
+
puts "reindex (async) #{collection} (started)"
|
|
80
|
+
else
|
|
81
|
+
res = QmdSync.reindex(collection: collection)
|
|
82
|
+
puts "reindexed #{collection} (#{res[:ok] ? "ok" : "warn"})"
|
|
83
|
+
end
|
|
76
84
|
exit 0
|
|
77
85
|
|
|
78
86
|
when "status"
|
|
@@ -86,7 +94,46 @@ when "status"
|
|
|
86
94
|
end
|
|
87
95
|
exit 0
|
|
88
96
|
|
|
97
|
+
when "search"
|
|
98
|
+
# The query is the first bareword that is NOT the value of a known value-taking
|
|
99
|
+
# flag. Walk ARGV, skipping each such flag and the token right after it, then
|
|
100
|
+
# take the first remaining token that does not start with "--".
|
|
101
|
+
value_flags = %w[--store --limit --min-score --home]
|
|
102
|
+
query = nil
|
|
103
|
+
i = 0
|
|
104
|
+
while i < ARGV.length
|
|
105
|
+
tok = ARGV[i]
|
|
106
|
+
if value_flags.include?(tok)
|
|
107
|
+
i += 2
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
unless tok.start_with?("--")
|
|
111
|
+
query = tok
|
|
112
|
+
break
|
|
113
|
+
end
|
|
114
|
+
i += 1
|
|
115
|
+
end
|
|
116
|
+
collections =
|
|
117
|
+
if (dir = opt(ARGV, "--store"))
|
|
118
|
+
[QmdSync.collection_name(dir, plastic_home: home), "plastic-global"].uniq
|
|
119
|
+
else
|
|
120
|
+
QmdSync.collections_for_cwd(Dir.pwd, plastic_home: home)
|
|
121
|
+
end
|
|
122
|
+
limit = (opt(ARGV, "--limit") || "5").to_i
|
|
123
|
+
min_score = (opt(ARGV, "--min-score") || "0.5").to_f
|
|
124
|
+
hits = QmdSync.search(query, collections: collections, limit: limit, min_score: min_score)
|
|
125
|
+
if hits.empty?
|
|
126
|
+
puts "no qmd hits"
|
|
127
|
+
else
|
|
128
|
+
hits.each do |h|
|
|
129
|
+
pct = (h[:score] * 100).round
|
|
130
|
+
path = h[:file].to_s.sub(%r{\Aqmd://}, "")
|
|
131
|
+
puts "[#{pct}%] #{path} - #{h[:title]}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
exit 0
|
|
135
|
+
|
|
89
136
|
else
|
|
90
|
-
warn "qmd-sync: unknown verb #{verb.inspect}. Use detect|register|reindex|status."
|
|
137
|
+
warn "qmd-sync: unknown verb #{verb.inspect}. Use detect|register|reindex|status|search."
|
|
91
138
|
exit 2
|
|
92
139
|
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# rebuild-graph — repair the store-wide sources/chain frontmatter graph across the
|
|
6
|
+
# global, plastic, and knowdb stores (intent 49). Deterministic, idempotent, and
|
|
7
|
+
# one-directional (intent 68 I-invariants): dedupe, I3 (formative edge wins), I1
|
|
8
|
+
# in-store backlinks, I2 preserved; cross-store refs resolved via a multi-hop
|
|
9
|
+
# relocation map (relocation wins over coincidental id reuse). Emits a
|
|
10
|
+
# before/after audit, then writes minimal style-preserving frontmatter.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# rebuild-graph [--plastic-home PATH] [--dry-run] [--audit-path PATH]
|
|
14
|
+
#
|
|
15
|
+
# Pure-Ruby (no bash). The pure logic lives in lib/graph_rebuild.rb and
|
|
16
|
+
# lib/frontmatter_writer.rb; this shell does only discovery, IO, and reporting.
|
|
17
|
+
# Never pushes ~/.plastic (no git ops here).
|
|
18
|
+
|
|
19
|
+
require "yaml"
|
|
20
|
+
require "date"
|
|
21
|
+
require "time"
|
|
22
|
+
require "fileutils"
|
|
23
|
+
|
|
24
|
+
require_relative "lib/graph_rebuild"
|
|
25
|
+
require_relative "lib/frontmatter_writer"
|
|
26
|
+
require_relative "lib/intent_validator"
|
|
27
|
+
|
|
28
|
+
class RebuildGraph
|
|
29
|
+
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
30
|
+
|
|
31
|
+
# The 49 intent dir audit destination (relative to plastic_home).
|
|
32
|
+
DEFAULT_AUDIT_REL =
|
|
33
|
+
"projects/plastic/store/49--store-wide-double-link-symmetry/resources/audit--graph-rebuild.md"
|
|
34
|
+
|
|
35
|
+
KIND_LABELS = {
|
|
36
|
+
dedupe: "Dedupes",
|
|
37
|
+
i3: "I3 resolutions (kept in sources, dropped from chain)",
|
|
38
|
+
repoint: "Cross-store repoints",
|
|
39
|
+
collapse: "Cross-store collapses (to bare same-store id)",
|
|
40
|
+
drop: "Dropped dead refs",
|
|
41
|
+
i1_backlink: "I1 backlinks added",
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
KIND_ORDER = %i[dedupe i3 repoint collapse drop i1_backlink].freeze
|
|
45
|
+
|
|
46
|
+
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
|
|
47
|
+
@plastic_home = plastic_home
|
|
48
|
+
@dry_run = dry_run
|
|
49
|
+
|
|
50
|
+
# A dry run must NOT stomp the canonical audit (the spec/checklist tell humans
|
|
51
|
+
# to run --dry-run to review the plan). When no explicit --audit-path is given,
|
|
52
|
+
# a dry run writes to a distinct `.dry-run.md` sibling, leaving the canonical
|
|
53
|
+
# real-run audit untouched. An explicit --audit-path is always honored verbatim
|
|
54
|
+
# (it is the caller's responsibility, and tests inject it).
|
|
55
|
+
canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
|
|
56
|
+
@audit_path =
|
|
57
|
+
if audit_path
|
|
58
|
+
audit_path
|
|
59
|
+
elsif dry_run
|
|
60
|
+
canonical.sub(/\.md\z/, ".dry-run.md")
|
|
61
|
+
else
|
|
62
|
+
canonical
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :plastic_home, :dry_run, :audit_path
|
|
67
|
+
|
|
68
|
+
# The three in-scope stores, each as { key:, root:, store:, index: }.
|
|
69
|
+
# `root` is the dir holding INDEX.md; `store` is the intents dir.
|
|
70
|
+
def stores
|
|
71
|
+
list = []
|
|
72
|
+
global_store = File.join(plastic_home, "store")
|
|
73
|
+
list << { key: "global", root: plastic_home, store: global_store,
|
|
74
|
+
index: File.join(plastic_home, "INDEX.md") } if File.directory?(global_store)
|
|
75
|
+
|
|
76
|
+
%w[plastic knowdb].each do |slug|
|
|
77
|
+
root = File.join(plastic_home, "projects", slug)
|
|
78
|
+
store = File.join(root, "store")
|
|
79
|
+
next unless File.directory?(store)
|
|
80
|
+
|
|
81
|
+
list << { key: "project:#{slug}", root: root, store: store,
|
|
82
|
+
index: File.join(root, "INDEX.md") }
|
|
83
|
+
end
|
|
84
|
+
list
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# { id => { sources:, chain:, path: } } for one store.
|
|
88
|
+
def load_nodes(store_dir)
|
|
89
|
+
nodes = {}
|
|
90
|
+
Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
91
|
+
dir = File.join(store_dir, entry)
|
|
92
|
+
next unless File.directory?(dir)
|
|
93
|
+
|
|
94
|
+
md = File.join(dir, "#{entry}.md")
|
|
95
|
+
next unless File.exist?(md)
|
|
96
|
+
|
|
97
|
+
fm = IntentValidator.parse_frontmatter(md)
|
|
98
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
99
|
+
|
|
100
|
+
nodes[fm["id"].to_s] = {
|
|
101
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
102
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
103
|
+
path: md,
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
nodes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run
|
|
110
|
+
store_list = stores
|
|
111
|
+
nodes_by_store = {}
|
|
112
|
+
index_texts = {}
|
|
113
|
+
store_index = {}
|
|
114
|
+
|
|
115
|
+
store_list.each do |s|
|
|
116
|
+
nodes_by_store[s[:key]] = load_nodes(s[:store])
|
|
117
|
+
index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
|
|
118
|
+
store_index[s[:key]] = nodes_by_store[s[:key]].keys
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
relocation_map = GraphRebuild.build_relocation_map(index_texts)
|
|
122
|
+
|
|
123
|
+
results = {}
|
|
124
|
+
store_list.each do |s|
|
|
125
|
+
key = s[:key]
|
|
126
|
+
input = nodes_by_store[key].transform_values { |v| { sources: v[:sources], chain: v[:chain] } }
|
|
127
|
+
results[key] = GraphRebuild.rebuild_store(
|
|
128
|
+
input,
|
|
129
|
+
referer_store: key,
|
|
130
|
+
relocation_map: relocation_map,
|
|
131
|
+
store_index: store_index
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
write_back(store_list, nodes_by_store, results) unless dry_run
|
|
136
|
+
emit_audit(store_list, nodes_by_store, results)
|
|
137
|
+
|
|
138
|
+
results
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Write changed frontmatter back via the minimal style-preserving writer.
|
|
142
|
+
def write_back(store_list, nodes_by_store, results)
|
|
143
|
+
store_list.each do |s|
|
|
144
|
+
key = s[:key]
|
|
145
|
+
new_nodes = results[key][:nodes]
|
|
146
|
+
nodes_by_store[key].each do |id, original|
|
|
147
|
+
rebuilt = new_nodes[id]
|
|
148
|
+
next if rebuilt.nil?
|
|
149
|
+
next if rebuilt[:sources] == original[:sources] && rebuilt[:chain] == original[:chain]
|
|
150
|
+
|
|
151
|
+
content = File.read(original[:path])
|
|
152
|
+
updated = FrontmatterWriter.rewrite_arrays(content,
|
|
153
|
+
sources: rebuilt[:sources],
|
|
154
|
+
chain: rebuilt[:chain])
|
|
155
|
+
File.write(original[:path], updated) if updated != content
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Render the audit and write it (always, even in dry-run, so the human reviews
|
|
161
|
+
# the dry-run plan). Returns the rendered string.
|
|
162
|
+
def emit_audit(store_list, _nodes_by_store, results)
|
|
163
|
+
text = render_audit(store_list, results)
|
|
164
|
+
FileUtils.mkdir_p(File.dirname(audit_path))
|
|
165
|
+
File.write(audit_path, text)
|
|
166
|
+
text
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# PURE-ish formatter (string from results). Per-store, grouped by kind.
|
|
170
|
+
def render_audit(store_list, results)
|
|
171
|
+
total = store_list.sum { |s| results[s[:key]][:changes].size }
|
|
172
|
+
lines = []
|
|
173
|
+
lines << "# Audit: store-wide sources/chain graph rebuild (intent 49)"
|
|
174
|
+
lines << ""
|
|
175
|
+
lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
|
|
176
|
+
lines << ""
|
|
177
|
+
lines << "Total changes across all stores: #{total}"
|
|
178
|
+
lines << ""
|
|
179
|
+
|
|
180
|
+
store_list.each do |s|
|
|
181
|
+
key = s[:key]
|
|
182
|
+
changes = results[key][:changes]
|
|
183
|
+
lines << "## #{key}"
|
|
184
|
+
lines << ""
|
|
185
|
+
if changes.empty?
|
|
186
|
+
lines << "No changes."
|
|
187
|
+
lines << ""
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
KIND_ORDER.each do |kind|
|
|
192
|
+
group = changes.select { |c| c[:kind] == kind }
|
|
193
|
+
next if group.empty?
|
|
194
|
+
|
|
195
|
+
lines << "### #{KIND_LABELS[kind]} (#{group.size})"
|
|
196
|
+
group.each { |c| lines << "- #{format_change(c)}" }
|
|
197
|
+
lines << ""
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
lines.join("\n") + "\n"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_change(c)
|
|
205
|
+
case c[:kind]
|
|
206
|
+
when :dedupe
|
|
207
|
+
"#{c[:intent]}: sources #{c[:before][:sources].inspect} → #{c[:after][:sources].inspect}, " \
|
|
208
|
+
"chain #{c[:before][:chain].inspect} → #{c[:after][:chain].inspect}"
|
|
209
|
+
when :i3
|
|
210
|
+
"#{c[:intent]}: #{c[:before]} kept in sources, dropped from chain"
|
|
211
|
+
when :repoint
|
|
212
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (relocated cross-store)"
|
|
213
|
+
when :collapse
|
|
214
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (collapsed to bare same-store id)"
|
|
215
|
+
when :drop
|
|
216
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} dropped (resolves nowhere)"
|
|
217
|
+
when :i1_backlink
|
|
218
|
+
"#{c[:intent]}.chain += #{c[:backlink]} (formative backlink)"
|
|
219
|
+
else
|
|
220
|
+
c.inspect
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if $PROGRAM_NAME == __FILE__
|
|
226
|
+
home = RebuildGraph::DEFAULT_HOME
|
|
227
|
+
dry = false
|
|
228
|
+
audit = nil
|
|
229
|
+
i = 0
|
|
230
|
+
while i < ARGV.length
|
|
231
|
+
case ARGV[i]
|
|
232
|
+
when "--plastic-home" then home = ARGV[i + 1]; i += 2
|
|
233
|
+
when "--dry-run" then dry = true; i += 1
|
|
234
|
+
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
235
|
+
else i += 1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
tool = RebuildGraph.new(plastic_home: home, dry_run: dry, audit_path: audit)
|
|
240
|
+
results = tool.run
|
|
241
|
+
total = results.values.sum { |r| r[:changes].size }
|
|
242
|
+
puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
|
|
243
|
+
puts "Audit: #{tool.audit_path}"
|
|
244
|
+
end
|
package/scripts/spawn-preamble
CHANGED
|
@@ -29,7 +29,22 @@ require_relative "lib/bridge"
|
|
|
29
29
|
HONOR_INSTRUCTION =
|
|
30
30
|
"You are operating inside Plastic. Use it as your operating scaffold. " \
|
|
31
31
|
"Emit VALID lifecycle artifacts; do not hallucinate intents or stages. " \
|
|
32
|
-
"Your output is
|
|
32
|
+
"Your primary output is valid lifecycle artifacts; you close with a structured report about them."
|
|
33
|
+
|
|
34
|
+
# Verbatim completion-report contract (intent 74). Kept as one constant so the
|
|
35
|
+
# contract doc (skills/auto/references/agent-report-contract.md), the role prompts,
|
|
36
|
+
# and the test assert against the exact same string. SINGLE SOURCE OF TRUTH for the
|
|
37
|
+
# report wording: the work output is lifecycle artifacts, the final message is a
|
|
38
|
+
# structured report about them. Both are required and they do not contradict.
|
|
39
|
+
REPORT_CONTRACT =
|
|
40
|
+
"Before you finish, END your turn with a structured completion report as your " \
|
|
41
|
+
"FINAL MESSAGE (your return value), not a side-channel file. Do not go idle or " \
|
|
42
|
+
"finish silently. The report carries a common envelope: role, intent id, stage, " \
|
|
43
|
+
"status (delivered or blocked), artifacts written, verification or tests run, " \
|
|
44
|
+
"checklist deltas, deviations from spec, and blockers or handoff notes; plus a " \
|
|
45
|
+
"role-specific payload that fulfils your place in the What, Why, How, Exec cycle " \
|
|
46
|
+
"(for example the planner explains the plan back to the orchestrator). See " \
|
|
47
|
+
"skills/auto/references/agent-report-contract.md for the per-role format."
|
|
33
48
|
|
|
34
49
|
def parse_args(argv)
|
|
35
50
|
role = nil
|
|
@@ -116,6 +131,8 @@ lines << "Current stage: #{stage}"
|
|
|
116
131
|
lines << "Cycle step / role: #{cycle}"
|
|
117
132
|
lines << ""
|
|
118
133
|
lines << HONOR_INSTRUCTION
|
|
134
|
+
lines << ""
|
|
135
|
+
lines << REPORT_CONTRACT
|
|
119
136
|
lines << "=== end preamble ==="
|
|
120
137
|
|
|
121
138
|
puts lines.join("\n")
|