@zalom/plastic 1.0.0-alpha.9 → 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 (115) hide show
  1. package/PLASTIC.md +170 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +45 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +47 -0
  6. package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
  7. package/agents/{intent-curator.md → plastic-intent-curator.md} +8 -6
  8. package/agents/plastic-planner.md +47 -0
  9. package/agents/plastic-spec-specialist.md +45 -0
  10. package/bin/plastic.js +57 -0
  11. package/bin/test +28 -0
  12. package/deprecations.yml +1 -10
  13. package/hooks/auto-arm +5 -0
  14. package/hooks/bash-gate +3 -0
  15. package/hooks/check-update +12 -8
  16. package/hooks/code-gate +12 -0
  17. package/hooks/create-gate +3 -0
  18. package/hooks/gate-check +3 -1
  19. package/hooks/hooks.json +52 -0
  20. package/hooks/qmd-search +8 -0
  21. package/hooks/statusline +150 -41
  22. package/package.json +2 -2
  23. package/scripts/agent-report +142 -0
  24. package/scripts/dashboard.rb +687 -0
  25. package/scripts/doctor.rb +1219 -621
  26. package/scripts/hook-auto-arm +51 -0
  27. package/scripts/hook-bash-gate +41 -0
  28. package/scripts/hook-code-gate +27 -0
  29. package/scripts/hook-continue +15 -114
  30. package/scripts/hook-create-gate +59 -0
  31. package/scripts/hook-gate-check +47 -32
  32. package/scripts/hook-qmd-search +44 -0
  33. package/scripts/hook-session-start +106 -38
  34. package/scripts/install.rb +91 -529
  35. package/scripts/lib/boot_banner.rb +28 -0
  36. package/scripts/lib/bridge.rb +452 -19
  37. package/scripts/lib/frontmatter_writer.rb +130 -0
  38. package/scripts/lib/graph_rebuild.rb +328 -0
  39. package/scripts/lib/installer_core.rb +812 -0
  40. package/scripts/lib/intent_validator.rb +235 -0
  41. package/scripts/lib/links_projection.rb +160 -0
  42. package/scripts/lib/links_section.rb +207 -0
  43. package/scripts/lib/power_tools.rb +76 -0
  44. package/scripts/lib/qmd_hook.rb +57 -0
  45. package/scripts/lib/qmd_sync.rb +230 -0
  46. package/scripts/lib/store_provisioning.rb +100 -0
  47. package/scripts/migrate-to-global +1 -1
  48. package/scripts/new-intent +327 -0
  49. package/scripts/project-links +287 -0
  50. package/scripts/provision-project-store +53 -0
  51. package/scripts/qmd-sync +139 -0
  52. package/scripts/rebuild-graph +244 -0
  53. package/scripts/select-update-target +93 -0
  54. package/scripts/spawn-preamble +138 -0
  55. package/scripts/uninstall.rb +53 -0
  56. package/scripts/update.rb +164 -0
  57. package/scripts/validate-intent +54 -0
  58. package/scripts/versions.rb +141 -0
  59. package/skills/_active-intent-gate.md +1 -1
  60. package/skills/add-project-store/SKILL.md +54 -0
  61. package/skills/auto/SKILL.md +87 -7
  62. package/skills/auto/evals/evals.json +255 -0
  63. package/skills/auto/references/agent-architecture.md +155 -0
  64. package/skills/auto/references/agent-report-contract.md +86 -0
  65. package/skills/brainstorming/SKILL.md +10 -9
  66. package/skills/brainstorming/evals/evals.json +22 -0
  67. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  68. package/skills/continuing/SKILL.md +99 -82
  69. package/skills/continuing/evals/evals.json +145 -0
  70. package/skills/continuing/references/context-management.md +32 -0
  71. package/skills/creating-intent/SKILL.md +82 -36
  72. package/skills/creating-intent/evals/evals.json +72 -0
  73. package/skills/creating-intent/references/lifecycle.md +81 -0
  74. package/skills/creating-intent/references/wikilinks.md +8 -0
  75. package/skills/creating-project/SKILL.md +40 -8
  76. package/skills/creating-project/references/hubs-projects.md +55 -0
  77. package/skills/dashboard/SKILL.md +126 -0
  78. package/skills/dashboard/evals/evals.json +22 -0
  79. package/skills/dashboard/templates/dashboard-global.md +31 -0
  80. package/skills/dashboard/templates/dashboard-project.md +40 -0
  81. package/skills/doctor/SKILL.md +51 -4
  82. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  83. package/skills/doctor/report.md +4 -0
  84. package/skills/evaluating-skills/SKILL.md +140 -0
  85. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  86. package/skills/evaluating-skills/evals/evals.json +75 -0
  87. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  88. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  89. package/skills/executing-plan/SKILL.md +5 -3
  90. package/skills/install/SKILL.md +69 -8
  91. package/skills/intent-curator/SKILL.md +6 -4
  92. package/skills/intent-curator/evals/evals.json +22 -0
  93. package/skills/linking-intents/SKILL.md +22 -7
  94. package/skills/linking-intents/evals/evals.json +22 -0
  95. package/skills/linking-intents/references/zettelkasten.md +45 -0
  96. package/skills/managing-index/SKILL.md +11 -1
  97. package/skills/managing-index/evals/evals.json +22 -0
  98. package/skills/managing-index/references/zettelkasten-linking.md +7 -2
  99. package/skills/releasing/SKILL.md +80 -23
  100. package/skills/releasing/references/deprecations.md +60 -0
  101. package/skills/research/SKILL.md +10 -2
  102. package/skills/research/evals/evals.json +22 -0
  103. package/skills/savepoint/SKILL.md +46 -37
  104. package/skills/savepoint/references/context-management.md +32 -0
  105. package/skills/uninstall/SKILL.md +39 -28
  106. package/skills/update/SKILL.md +41 -44
  107. package/skills/versions/SKILL.md +65 -0
  108. package/skills/writing-instructions/SKILL.md +159 -0
  109. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  110. package/skills/writing-plans/SKILL.md +5 -5
  111. package/templates/agents.md +7 -7
  112. package/templates/outcome.md +13 -0
  113. package/templates/savepoint.md +14 -13
  114. package/templates/spec.md +25 -0
  115. package/bin/install.js +0 -29
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # new-intent (intent 60b) - the one-call scaffolding contract.
6
+ #
7
+ # A single invocation scaffolds a COMPLETE intent: it allocates the Folgezettel
8
+ # id (root vs branch by --parent), creates the directory tree (<id>--<slug>/ plus
9
+ # actions/ and resources/), renders the born-complete intent file from
10
+ # templates/intent.md, writes sentinel placeholder lifecycle files (spec.md,
11
+ # plan.md, checklist.md, outcome.md, each carrying <!-- plastic:placeholder -->
12
+ # as its first line), wires reciprocal file links, and self-validates with
13
+ # IntentValidator (exit non-zero if not born complete).
14
+ #
15
+ # It does NOT touch INDEX.md, git, or project creation: those remain skill/agent
16
+ # responsibilities (see plastic-creating-intent).
17
+ #
18
+ # Usage:
19
+ # new-intent --store <store_path> --intent "<one-line>" --slug <slug> \
20
+ # [--parent <id>] [--author <name>] [--sources id,id] \
21
+ # [--tags tag,tag] [--templates <dir>]
22
+ #
23
+ # Exit codes: 0 (born complete), 1 (scaffold failed self-validation or bad input).
24
+
25
+ require "fileutils"
26
+ require "date"
27
+ require_relative "lib/bridge"
28
+ require_relative "lib/intent_validator"
29
+ require_relative "lib/graph_rebuild"
30
+ require_relative "lib/links_projection"
31
+ require_relative "lib/links_section"
32
+
33
+ # --- Explicit flag parsing (no eval, no global injection) ------------------
34
+
35
+ def parse_args(argv)
36
+ opts = {
37
+ store: nil, intent: nil, slug: nil, parent: nil,
38
+ author: "claude-code", sources: [], tags: [], templates: nil
39
+ }
40
+ i = 0
41
+ while i < argv.length
42
+ arg = argv[i]
43
+ case arg
44
+ when "--store" then opts[:store] = argv[i += 1]
45
+ when "--intent" then opts[:intent] = argv[i += 1]
46
+ when "--slug" then opts[:slug] = argv[i += 1]
47
+ when "--parent" then opts[:parent] = argv[i += 1]
48
+ when "--author" then opts[:author] = argv[i += 1]
49
+ when "--sources" then opts[:sources] = split_list(argv[i += 1])
50
+ when "--tags" then opts[:tags] = split_list(argv[i += 1])
51
+ when "--templates" then opts[:templates] = argv[i += 1]
52
+ else
53
+ abort "new-intent: unknown argument #{arg.inspect}"
54
+ end
55
+ i += 1
56
+ end
57
+ opts
58
+ end
59
+
60
+ def split_list(value)
61
+ value.to_s.split(",").map(&:strip).reject(&:empty?)
62
+ end
63
+
64
+ def expand(path)
65
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
66
+ end
67
+
68
+ # Default templates dir: sibling of this script's dir. Works in-repo
69
+ # (<repo>/scripts/new-intent -> <repo>/templates) and installed
70
+ # (~/.plastic/scripts/new-intent -> ~/.plastic/templates when present).
71
+ def default_templates_dir
72
+ File.expand_path("../templates", __dir__)
73
+ end
74
+
75
+ def render_tokens(text, tokens)
76
+ tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}", v.to_s) }
77
+ end
78
+
79
+ # Add an id to a source intent's frontmatter `chain` array, idempotently (I1
80
+ # reciprocity: `child in parent.sources` => `parent.chain` gains `child`). A
81
+ # targeted edit of the `chain:` line only; the body (including `## Links`) is
82
+ # preserved byte-for-byte and no other frontmatter key is touched, so the file
83
+ # stays born-complete. Renders the array in flow style (`["a", "b"]`) to match
84
+ # templates/intent.md. No-op when the id is already present.
85
+ def add_to_chain(file_path, new_id)
86
+ return unless File.exist?(file_path)
87
+ content = File.read(file_path)
88
+ return unless content.start_with?("---")
89
+
90
+ parts = content.split("---", 3)
91
+ return unless parts.length >= 3
92
+
93
+ fm = parts[1]
94
+ chain_line = fm.lines.find { |l| l.match?(/\A\s*chain\s*:/) }
95
+ return unless chain_line
96
+
97
+ existing = fm.match(/\bchain\s*:\s*\[(.*?)\]/m)
98
+ ids =
99
+ if existing
100
+ existing[1].scan(/"([^"]*)"|'([^']*)'/).flatten.compact
101
+ else
102
+ []
103
+ end
104
+ return if ids.include?(new_id)
105
+
106
+ ids << new_id
107
+ rendered = "chain: [#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
108
+ new_fm = fm.sub(/^\s*chain\s*:.*$/, rendered)
109
+ File.write(file_path, ["", new_fm, parts[2]].join("---"))
110
+ end
111
+
112
+ # Derive [plastic_home, referer_store_key] from a store directory path so the
113
+ # cross-store resolver (shared with project-links and the doctor check) can be
114
+ # built. A global store is `<home>/store` (key "global"); a project store is
115
+ # `<home>/projects/<slug>/store` (key "project:<slug>"). Returns
116
+ # [nil, "global"] only if the layout is unrecognized, in which case Links
117
+ # projection falls back to single-store resolution rooted at this store.
118
+ def store_context(store)
119
+ store = File.expand_path(store)
120
+ parent = File.dirname(store) # `<home>` or `<home>/projects/<slug>`
121
+ if File.basename(store) == "store" && File.basename(File.dirname(parent)) == "projects"
122
+ slug = File.basename(parent)
123
+ home = File.dirname(File.dirname(parent)) # strip projects/<slug>
124
+ [home, "project:#{slug}"]
125
+ elsif File.basename(store) == "store"
126
+ [parent, "global"] # `<home>/store`
127
+ else
128
+ [parent, "global"]
129
+ end
130
+ end
131
+
132
+ # The in-scope stores under `plastic_home`, each { key:, store: }. Mirrors
133
+ # project-links/RebuildGraph#stores so cross-store resolution spans the family.
134
+ def family_stores(plastic_home)
135
+ list = []
136
+ global_store = File.join(plastic_home, "store")
137
+ list << { key: "global", store: global_store } if File.directory?(global_store)
138
+ %w[plastic knowdb].each do |slug|
139
+ store = File.join(plastic_home, "projects", slug, "store")
140
+ list << { key: "project:#{slug}", store: store } if File.directory?(store)
141
+ end
142
+ list
143
+ end
144
+
145
+ # Build the cross-store maps the LinksProjection resolver needs:
146
+ # store_index => { store_key => [bare ids] }
147
+ # node_index => { store_key => { id => { basename:, label: } } }
148
+ # relocation_map => from GraphRebuild.build_relocation_map over every INDEX.md
149
+ # `fallback_store` is the store the new intent lives in; it is always included so
150
+ # resolution works even for a brand-new store with no INDEX.md yet.
151
+ def build_cross_store_maps(plastic_home, fallback_store_key, fallback_store_dir)
152
+ stores = family_stores(plastic_home)
153
+ # Ensure the fallback store is represented even if family discovery missed it.
154
+ unless stores.any? { |s| s[:key] == fallback_store_key }
155
+ stores << { key: fallback_store_key, store: fallback_store_dir }
156
+ end
157
+
158
+ store_index = Hash.new { |h, k| h[k] = [] }
159
+ node_index = Hash.new { |h, k| h[k] = {} }
160
+ index_texts = {}
161
+
162
+ stores.each do |s|
163
+ next unless File.directory?(s[:store])
164
+
165
+ Dir.children(s[:store]).reject { |e| e.start_with?(".") }.sort.each do |entry|
166
+ dir = File.join(s[:store], entry)
167
+ next unless File.directory?(dir)
168
+
169
+ md = File.join(dir, "#{entry}.md")
170
+ next unless File.exist?(md)
171
+
172
+ fm = IntentValidator.parse_frontmatter(md)
173
+ next unless fm.is_a?(Hash) && fm["id"]
174
+
175
+ id = fm["id"].to_s
176
+ store_index[s[:key]] << id
177
+ node_index[s[:key]][id] = { basename: entry, label: fm["intent"].to_s.strip }
178
+ end
179
+
180
+ idx = File.join(File.dirname(s[:store]), "INDEX.md")
181
+ index_texts[s[:key]] = File.read(idx) if File.exist?(idx)
182
+ end
183
+
184
+ relocation_map = GraphRebuild.build_relocation_map(index_texts)
185
+ { store_index: store_index, node_index: node_index, relocation_map: relocation_map }
186
+ end
187
+
188
+ # Re-project ONE intent file's `## Links` as the canonical I5 projection of its
189
+ # OWN frontmatter sources+chain, using the shared cross-store resolver. Writes the
190
+ # file only if the section changed (idempotent). Born-canonical: a fresh root with
191
+ # no edges gets the empty-state comment; the fence-aware rewriter touches only the
192
+ # real `## Links` section. Returns true on success, false when the intent could
193
+ # not be read or a ref was unresolvable (left unwritten, never a guessed link).
194
+ def project_links_for(file_path, referer_store_key, maps)
195
+ return false unless File.exist?(file_path)
196
+
197
+ content = File.read(file_path)
198
+ fm = IntentValidator.parse_frontmatter_text(content)
199
+ return false unless fm.is_a?(Hash)
200
+
201
+ resolve = lambda do |ref|
202
+ LinksProjection.resolve_ref_projection(
203
+ ref,
204
+ referer_store: referer_store_key,
205
+ relocation_map: maps[:relocation_map],
206
+ store_index: maps[:store_index],
207
+ node_index: maps[:node_index]
208
+ )
209
+ end
210
+
211
+ begin
212
+ section_text = LinksProjection.section(
213
+ sources: Array(fm["sources"]).map(&:to_s),
214
+ chain: Array(fm["chain"]).map(&:to_s),
215
+ resolve: resolve
216
+ )
217
+ updated = LinksSection.rewrite(content, section_text)
218
+ rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
219
+ warn "new-intent: could not project ## Links for #{File.basename(file_path)}: #{e.message}"
220
+ return false
221
+ end
222
+
223
+ File.write(file_path, updated) if updated != content
224
+ true
225
+ end
226
+
227
+ def main(argv)
228
+ opts = parse_args(argv)
229
+ abort "new-intent: --store is required" if opts[:store].nil? || opts[:store].empty?
230
+ abort "new-intent: --intent is required" if opts[:intent].nil? || opts[:intent].empty?
231
+ abort "new-intent: --slug is required" if opts[:slug].nil? || opts[:slug].empty?
232
+
233
+ store = expand(opts[:store])
234
+ abort "new-intent: store dir does not exist: #{store}" unless Dir.exist?(store)
235
+
236
+ templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
237
+ abort "new-intent: templates dir not found: #{templates}" unless Dir.exist?(templates)
238
+
239
+ # 1. Allocate id via the existing folgezettel-id logic (root vs branch).
240
+ folg = File.expand_path("folgezettel-id", __dir__)
241
+ cmd = [folg, store]
242
+ cmd << opts[:parent] if opts[:parent] && !opts[:parent].empty?
243
+ id = `#{cmd.map { |c| "'#{c}'" }.join(" ")}`.strip
244
+ abort "new-intent: id allocation failed" if id.empty?
245
+
246
+ slug = opts[:slug]
247
+ intent_dir = File.join(store, "#{id}--#{slug}")
248
+ abort "new-intent: #{intent_dir} already exists" if File.exist?(intent_dir)
249
+
250
+ # 2. Create dirs.
251
+ FileUtils.mkdir_p(File.join(intent_dir, "actions"))
252
+ FileUtils.mkdir_p(File.join(intent_dir, "resources"))
253
+
254
+ # 3. Render the born-complete intent file from templates/intent.md.
255
+ sources = opts[:sources]
256
+ sources = sources | [opts[:parent]] if opts[:parent] && !opts[:parent].empty? && !sources.include?(opts[:parent])
257
+ sources_str = sources.map { |s| "\"#{s}\"" }.join(", ")
258
+ tags_str = opts[:tags].map { |t| "\"#{t}\"" }.join(", ")
259
+
260
+ intent_template = File.read(File.join(templates, "intent.md"))
261
+ intent_body = render_tokens(intent_template, {
262
+ "ID" => id,
263
+ "INTENT" => opts[:intent],
264
+ "SOURCES" => sources_str,
265
+ "DATE" => Date.today.iso8601,
266
+ "AUTHOR" => opts[:author],
267
+ "TAGS" => tags_str,
268
+ "DESCRIPTION" => opts[:intent],
269
+ })
270
+ intent_file = File.join(intent_dir, "#{id}--#{slug}.md")
271
+ File.write(intent_file, intent_body)
272
+
273
+ # 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
274
+ # `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
275
+ # `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
276
+ # folds in `--parent`). Collect the touched source files so their `## Links` can be
277
+ # re-projected once the chain edges are on disk.
278
+ source_files = []
279
+ sources.each do |src_id|
280
+ next if src_id.nil? || src_id.empty?
281
+
282
+ src_dir = Dir.glob(File.join(store, "#{src_id}--*")).find { |d| File.directory?(d) }
283
+ next unless src_dir
284
+
285
+ src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
286
+ add_to_chain(src_file, id)
287
+ source_files << src_file
288
+ end
289
+
290
+ # 4b. Canonical `## Links` projection (intent 72): born-canonical, drift PREVENTED
291
+ # at the source. Build the cross-store resolver maps AFTER the chain backlinks are
292
+ # written, then project the NEW intent's Links from its own frontmatter (sources at
293
+ # birth, chain empty) and RE-project each source/parent's Links (it just gained the
294
+ # new id in its chain). The fence-aware rewriter and the resolved-target dedup are
295
+ # the same ones project-links and the doctor check use, so a freshly created intent
296
+ # and its sources both PASS graph_links_projection. A no-source root gets the
297
+ # canonical empty-state comment.
298
+ plastic_home, referer_store_key = store_context(store)
299
+ maps = build_cross_store_maps(plastic_home, referer_store_key, store)
300
+
301
+ project_links_for(intent_file, referer_store_key, maps)
302
+ source_files.uniq.each { |sf| project_links_for(sf, referer_store_key, maps) }
303
+
304
+ # 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
305
+ # line; the rendered template body follows so the file is a usable starting
306
+ # point once an agent deletes the sentinel.
307
+ %w[spec.md plan.md checklist.md outcome.md].each do |name|
308
+ template_path = File.join(templates, name)
309
+ body = File.exist?(template_path) ? File.read(template_path) : ""
310
+ body = render_tokens(body, { "INTENT_NAME" => opts[:intent] })
311
+ File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
312
+ end
313
+
314
+ # 6. Self-validate (frontmatter + sanctioned sections).
315
+ result = IntentValidator.validate(intent_dir)
316
+ unless result[:ok]
317
+ warn "new-intent: scaffolded intent is NOT born complete:"
318
+ result[:missing].each { |f| warn " missing field: #{f}" }
319
+ result[:errors].each { |e| warn " #{e}" }
320
+ exit 1
321
+ end
322
+
323
+ puts intent_dir
324
+ exit 0
325
+ end
326
+
327
+ main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -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
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # provision-project-store — deterministic CLI over StoreProvisioning (intent 61).
6
+ #
7
+ # Creates a registered project's store at ~/.plastic/projects/{slug}/store: it
8
+ # makes the store directory, then writes, only if missing, .gitkeep, INDEX.md,
9
+ # and project.yml. It is pure filesystem and idempotent, so re-running never
10
+ # clobbers existing files and performs no qmd mutation. The project must already
11
+ # be registered in projects.yml; an unregistered slug exits non-zero and creates
12
+ # nothing.
13
+ #
14
+ # Usage:
15
+ # provision-project-store <slug> [--home <path>]
16
+ #
17
+ # Exit codes: 0 (store provisioned), 1 (unregistered slug / error on stderr),
18
+ # 2 (usage). --home <path> overrides the Plastic home (default: ~/.plastic).
19
+
20
+ require_relative "lib/store_provisioning"
21
+
22
+ def plastic_home(args)
23
+ if (i = args.index("--home")) && args[i + 1]
24
+ File.expand_path(args[i + 1])
25
+ else
26
+ File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
27
+ end
28
+ end
29
+
30
+ home = plastic_home(ARGV)
31
+
32
+ # First positional arg, skipping any flag and the value that follows --home.
33
+ home_value_index = (i = ARGV.index("--home")) ? i + 1 : nil
34
+ slug_index = ARGV.each_index.find do |idx|
35
+ arg = ARGV[idx]
36
+ !arg.start_with?("--") && idx != home_value_index
37
+ end
38
+ slug = slug_index && ARGV[slug_index]
39
+
40
+ if slug.nil?
41
+ warn "usage: provision-project-store <slug> [--home <path>]"
42
+ exit 2
43
+ end
44
+
45
+ result = StoreProvisioning.provision(slug, plastic_home: home)
46
+
47
+ if result[:ok]
48
+ puts "OK: #{result[:store_dir]}"
49
+ exit 0
50
+ end
51
+
52
+ warn result[:error]
53
+ exit 1