@zalom/plastic 1.0.0-beta.1 → 1.0.0-beta.10

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 (50) hide show
  1. package/PLASTIC.md +21 -5
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-executor.md +10 -0
  4. package/agents/plastic-intent-curator.md +7 -5
  5. package/agents/plastic-planner.md +11 -1
  6. package/agents/plastic-spec-specialist.md +9 -1
  7. package/hooks/statusline +150 -41
  8. package/package.json +1 -1
  9. package/scripts/agent-report +142 -0
  10. package/scripts/dashboard.rb +5 -3
  11. package/scripts/doctor.rb +243 -0
  12. package/scripts/lib/bridge.rb +72 -24
  13. package/scripts/lib/frontmatter_writer.rb +130 -0
  14. package/scripts/lib/graph_rebuild.rb +328 -0
  15. package/scripts/lib/installer_core.rb +5 -0
  16. package/scripts/lib/intent_validator.rb +79 -0
  17. package/scripts/lib/links_projection.rb +160 -0
  18. package/scripts/lib/links_section.rb +207 -0
  19. package/scripts/lib/power_tools.rb +76 -0
  20. package/scripts/lib/qmd_hook.rb +38 -25
  21. package/scripts/lib/qmd_sync.rb +21 -0
  22. package/scripts/new-intent +172 -22
  23. package/scripts/project-links +287 -0
  24. package/scripts/qmd-sync +50 -3
  25. package/scripts/rebuild-graph +244 -0
  26. package/scripts/spawn-preamble +18 -1
  27. package/skills/auto/SKILL.md +13 -3
  28. package/skills/auto/evals/evals.json +48 -0
  29. package/skills/auto/references/agent-architecture.md +20 -0
  30. package/skills/auto/references/agent-report-contract.md +86 -0
  31. package/skills/brainstorming/SKILL.md +1 -0
  32. package/skills/brainstorming/evals/evals.json +22 -0
  33. package/skills/continuing/SKILL.md +8 -1
  34. package/skills/continuing/evals/evals.json +9 -0
  35. package/skills/creating-intent/SKILL.md +28 -8
  36. package/skills/creating-intent/evals/evals.json +72 -0
  37. package/skills/creating-intent/references/lifecycle.md +12 -4
  38. package/skills/dashboard/SKILL.md +5 -0
  39. package/skills/dashboard/evals/evals.json +22 -0
  40. package/skills/executing-plan/SKILL.md +2 -2
  41. package/skills/intent-curator/SKILL.md +3 -1
  42. package/skills/intent-curator/evals/evals.json +22 -0
  43. package/skills/linking-intents/SKILL.md +17 -6
  44. package/skills/linking-intents/evals/evals.json +22 -0
  45. package/skills/linking-intents/references/zettelkasten.md +15 -3
  46. package/skills/managing-index/SKILL.md +6 -0
  47. package/skills/managing-index/evals/evals.json +22 -0
  48. package/skills/managing-index/references/zettelkasten-linking.md +7 -2
  49. package/skills/research/SKILL.md +8 -0
  50. 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
- res = QmdSync.reindex(collection: collection)
75
- puts "reindexed #{collection} (#{res[:ok] ? "ok" : "warn"})"
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
@@ -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 a deliverable, not a message."
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")
@@ -28,6 +28,13 @@ Work `dispatchable_queue` in `rank` order (these are `defer`/`research` disposit
28
28
  safe to deliver autonomously). Leave `human_only` and `next_big_thing` for the user — those
29
29
  are `drive`/`triage` items the human should lead. See the `plastic-dashboard` skill.
30
30
 
31
+ QMD-first (when available): when the user describes the work to deliver rather than naming an
32
+ intent, before scanning the store with grep/Read run
33
+ `ruby ~/.plastic/scripts/qmd-sync search "<terms>"` to surface candidate, prior, or duplicate
34
+ intents, then open the authoritative intent file for the hit you take over. The command is a no-op
35
+ when QMD is absent, so fall back to the existing INDEX.md / file scan. (This is discovery; the
36
+ reindex step under Completion is separate.)
37
+
31
38
  ## Arm the Lifecycle Gate (do this FIRST)
32
39
 
33
40
  Immediately after selecting the intent — before any other work — arm auto mode. This
@@ -68,6 +75,8 @@ Dispatch rule: sequential, one specialist per stage on one branch (the deliverab
68
75
 
69
76
  Spawn preamble (live-state injection): before dispatching any specialist, run `scripts/spawn-preamble <intent_dir> --role <role>` and PREPEND its output to that specialist's prompt. The preamble is a deterministic, filesystem-only snapshot of the active intent (id, intent line, current stage) plus the honoring instruction, so every spawned agent boots with accurate live state instead of guessing. This is the authoritative L2 mechanism for harnesses whose sub-agents do not inherit a top-level session event (see `docs/reference/harness-adapters.md`).
70
77
 
78
+ Completion report (require-then-synthesize): every dispatched specialist MUST end with a structured completion report as its final message. The preamble's `REPORT_CONTRACT` injects this and the role prompts carry the per-role format (see `references/agent-report-contract.md`). Because child-agent honor is best-effort across harnesses, this is decision-shaping, not a hard block. When a specialist returns no usable report (it went idle, emitted only a bare ping, or its message was lost to a mid-run interjection), run `scripts/agent-report <intent_dir> --role <role>` to synthesize a deterministic filesystem-derived report so the handoff account always exists. Use the agent-authored report when present, the synthesized one otherwise.
79
+
71
80
  Final-gate review: dispatch an independent reviewer subagent at the final gate only, not as a standing role.
72
81
 
73
82
  Headless manual gate: when running headless or in the background, enforce gates manually and do not rely on hooks, because `CLAUDE_SESSION_ID` may be unset (this ties to the arm-gate fallback above).
@@ -184,11 +193,12 @@ During initial project creation, all decisions are non-destructive by definition
184
193
  - Update `chain` in the current intent's frontmatter
185
194
  6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
186
195
  7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
187
- 8. Refresh the QMD search index for this store (optional, no-op when QMD is absent):
196
+ 8. On completion, ALWAYS refresh the QMD search index for this store (no-op when QMD is absent).
197
+ It runs in the background so it never blocks the turn:
188
198
  ```bash
189
- ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root>
199
+ ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async
190
200
  ```
191
- Delivery is the lifecycle event that keeps the search index fresh. `<store-root>` is the
201
+ Completion is the lifecycle event that keeps the search index fresh. `<store-root>` is the
192
202
  store that holds this intent (the global store or the project store).
193
203
  9. Disarm the lifecycle gate (auto delivery is finished):
194
204
  ```bash