@zalom/plastic 1.0.0-beta.3 → 1.0.0-beta.30

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 (102) hide show
  1. package/PLASTIC.md +293 -7
  2. package/README.md +10 -0
  3. package/agents/plastic-brainstorming.md +2 -2
  4. package/agents/plastic-enforcer.md +17 -2
  5. package/agents/plastic-executor.md +3 -3
  6. package/agents/plastic-future-intent-researcher.md +2 -1
  7. package/agents/plastic-intent-curator.md +15 -7
  8. package/agents/plastic-intent-discovery.md +37 -0
  9. package/agents/plastic-planner.md +2 -2
  10. package/agents/plastic-spec-specialist.md +2 -2
  11. package/hooks/code-gate +10 -1
  12. package/hooks/hooks.json +28 -3
  13. package/hooks/lock-gate +21 -0
  14. package/hooks/retrieval-gate +10 -0
  15. package/hooks/savepoint-pre +10 -0
  16. package/hooks/statusline +150 -41
  17. package/package.json +1 -1
  18. package/scripts/agent-report +21 -0
  19. package/scripts/doctor.rb +404 -5
  20. package/scripts/hook-auto-arm +1 -1
  21. package/scripts/hook-bash-gate +22 -4
  22. package/scripts/hook-code-gate +11 -6
  23. package/scripts/hook-create-gate +45 -9
  24. package/scripts/hook-gate-check +24 -23
  25. package/scripts/hook-lock-gate +83 -0
  26. package/scripts/hook-retrieval-gate +148 -0
  27. package/scripts/hook-savepoint-pre +32 -0
  28. package/scripts/hook-session-start +1 -1
  29. package/scripts/insight-append +51 -0
  30. package/scripts/lib/agent_models.rb +43 -0
  31. package/scripts/lib/bridge.rb +625 -47
  32. package/scripts/lib/frontmatter_writer.rb +130 -0
  33. package/scripts/lib/graph_rebuild.rb +328 -0
  34. package/scripts/lib/hook_registry.rb +95 -0
  35. package/scripts/lib/insights.rb +86 -0
  36. package/scripts/lib/installer_core.rb +64 -54
  37. package/scripts/lib/link_suggestions.rb +319 -0
  38. package/scripts/lib/links_projection.rb +160 -0
  39. package/scripts/lib/links_section.rb +207 -0
  40. package/scripts/lib/lock.rb +375 -0
  41. package/scripts/lib/power_tools.rb +75 -0
  42. package/scripts/lib/qmd_hook.rb +38 -25
  43. package/scripts/lib/qmd_sync.rb +36 -0
  44. package/scripts/lib/retrieval_gate.rb +211 -0
  45. package/scripts/lib/worktree.rb +384 -0
  46. package/scripts/link-suggest +213 -0
  47. package/scripts/new-intent +138 -29
  48. package/scripts/plastic-lock +164 -0
  49. package/scripts/project-links +287 -0
  50. package/scripts/qmd-sync +50 -3
  51. package/scripts/read-config +4 -0
  52. package/scripts/rebuild-graph +244 -0
  53. package/scripts/spawn-preamble +11 -3
  54. package/skills/auto/SKILL.md +98 -16
  55. package/skills/auto/evals/evals.json +48 -0
  56. package/skills/auto/references/agent-architecture.md +7 -4
  57. package/skills/auto/references/agent-report-contract.md +36 -1
  58. package/skills/brainstorming/SKILL.md +1 -0
  59. package/skills/brainstorming/evals/evals.json +22 -0
  60. package/skills/continuing/SKILL.md +30 -8
  61. package/skills/continuing/evals/evals.json +9 -0
  62. package/skills/creating-intent/SKILL.md +16 -2
  63. package/skills/creating-intent/evals/evals.json +16 -0
  64. package/skills/creating-intent/references/lifecycle.md +9 -4
  65. package/skills/creating-skills/SKILL.md +65 -0
  66. package/skills/creating-skills/evals/evals.json +108 -0
  67. package/skills/creating-skills/references/agents.md +168 -0
  68. package/skills/creating-skills/references/evals.md +41 -0
  69. package/skills/creating-skills/references/hooks.md +248 -0
  70. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  71. package/skills/creating-skills/references/scripts.md +166 -0
  72. package/skills/creating-skills/references/skills.md +165 -0
  73. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  74. package/skills/dashboard/SKILL.md +5 -0
  75. package/skills/dashboard/evals/evals.json +22 -0
  76. package/skills/doctor/SKILL.md +4 -1
  77. package/skills/executing-plan/SKILL.md +4 -4
  78. package/skills/humanizer/SKILL.md +39 -0
  79. package/skills/humanizer/evals/evals.json +70 -0
  80. package/skills/humanizer/references/always-on-snippet.md +9 -0
  81. package/skills/humanizer/references/examples.md +48 -0
  82. package/skills/intent-curator/SKILL.md +7 -1
  83. package/skills/intent-curator/evals/evals.json +22 -0
  84. package/skills/intent-discovery/SKILL.md +46 -0
  85. package/skills/intent-starting/SKILL.md +127 -0
  86. package/skills/intent-starting/evals/evals.json +117 -0
  87. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  88. package/skills/linking-intents/SKILL.md +54 -12
  89. package/skills/linking-intents/evals/evals.json +22 -0
  90. package/skills/linking-intents/references/zettelkasten.md +7 -0
  91. package/skills/lock/SKILL.md +41 -0
  92. package/skills/managing-index/SKILL.md +12 -0
  93. package/skills/managing-index/evals/evals.json +22 -0
  94. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  95. package/skills/releasing/SKILL.md +32 -0
  96. package/skills/research/SKILL.md +8 -0
  97. package/skills/research/evals/evals.json +22 -0
  98. package/templates/config.yml +8 -0
  99. package/templates/outcome.md +3 -0
  100. package/templates/revisions.md +58 -0
  101. package/skills/writing-instructions/SKILL.md +0 -159
  102. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -26,6 +26,9 @@ require "fileutils"
26
26
  require "date"
27
27
  require_relative "lib/bridge"
28
28
  require_relative "lib/intent_validator"
29
+ require_relative "lib/graph_rebuild"
30
+ require_relative "lib/links_projection"
31
+ require_relative "lib/links_section"
29
32
 
30
33
  # --- Explicit flag parsing (no eval, no global injection) ------------------
31
34
 
@@ -106,25 +109,119 @@ def add_to_chain(file_path, new_id)
106
109
  File.write(file_path, ["", new_fm, parts[2]].join("---"))
107
110
  end
108
111
 
109
- # Append a wikilink line under the file's `## Links` section, idempotently.
110
- def append_link(file_path, link_line)
111
- return unless File.exist?(file_path)
112
- content = File.read(file_path)
113
- return if content.include?(link_line)
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
114
144
 
115
- lines = content.lines
116
- idx = lines.index { |l| l.strip == "## Links" }
117
- return unless idx
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)
118
168
 
119
- insert_at = lines.length
120
- ((idx + 1)...lines.length).each do |j|
121
- if lines[j].strip.start_with?("## ")
122
- insert_at = j
123
- break
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 }
124
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)
125
182
  end
126
- lines.insert(insert_at, "#{link_line}\n")
127
- File.write(file_path, lines.join)
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
128
225
  end
129
226
 
130
227
  def main(argv)
@@ -176,8 +273,9 @@ def main(argv)
176
273
  # 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
177
274
  # `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
178
275
  # `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
179
- # folds in `--parent`). Scope boundary: 68 fixes ONLY the frontmatter `chain`
180
- # backlink; the `## Links` wikilink projection for the `--sources` path is intent 72.
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 = []
181
279
  sources.each do |src_id|
182
280
  next if src_id.nil? || src_id.empty?
183
281
 
@@ -186,19 +284,22 @@ def main(argv)
186
284
 
187
285
  src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
188
286
  add_to_chain(src_file, id)
287
+ source_files << src_file
189
288
  end
190
289
 
191
- # 4b. Reciprocal `## Links` wikilinks: forward link to parent + back-reference in
192
- # the parent (kept parent-only; the `--sources` path's wikilink is intent 72's scope).
193
- if opts[:parent] && !opts[:parent].empty?
194
- parent_id = opts[:parent]
195
- append_link(intent_file, "- [[#{parent_id}]]")
196
- parent_dir = Dir.glob(File.join(store, "#{parent_id}--*")).find { |d| File.directory?(d) }
197
- if parent_dir
198
- parent_file = File.join(parent_dir, "#{File.basename(parent_dir)}.md")
199
- append_link(parent_file, "- [[#{id}]]")
200
- end
201
- end
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) }
202
303
 
203
304
  # 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
204
305
  # line; the rendered template body follows so the file is a usable starting
@@ -210,7 +311,15 @@ def main(argv)
210
311
  File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
211
312
  end
212
313
 
213
- # 6. Self-validate (frontmatter + sanctioned sections).
314
+ # 6. Stamp the born savepoint line (intent 81). The first ledger line is the
315
+ # `What` bookend, written deterministically at creation rather than relying on
316
+ # a PostToolUse gate firing on the intent-file write (which is missed in some
317
+ # sessions / harnesses). The intent file is never a sentinel placeholder, so
318
+ # append_savepoint records `What {id}--{slug}.md`. Idempotent: a later gate
319
+ # fire adds nothing.
320
+ Bridge.append_savepoint(intent_dir, intent_file)
321
+
322
+ # 7. Self-validate (frontmatter + sanctioned sections).
214
323
  result = IntentValidator.validate(intent_dir)
215
324
  unless result[:ok]
216
325
  warn "new-intent: scaffolded intent is NOT born complete:"
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ #
5
+ # plastic-lock: inspect and repair the durable delivery lock (intent 108, D5),
6
+ # and take/free per-artifact claim tokens (intent 111 D1/D5).
7
+ #
8
+ # Usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim>
9
+ # [--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]
10
+ #
11
+ # Verbs:
12
+ # status report the lock file, the bridge cache, their agreement,
13
+ # and any live per-artifact claims
14
+ # fix idempotent repair: rebuild lock + bridge from disk truth for
15
+ # the current session; never touches a fresh foreign lock
16
+ # release owner clears the lock (End tail / abandoning a boarding)
17
+ # reclaim explicit takeover of a stale lock, audited in savepoint.md
18
+ # delegate owner registers a subagent session under the lock (D4)
19
+ # claim acquire the named --artifact's claim token (intent 111);
20
+ # rejected (exit 1) while a fresh claim is held by anyone,
21
+ # including this same session; takes over a stale claim
22
+ # release-claim free the named --artifact's claim token
23
+ #
24
+ # Without --intent-dir the intent is resolved from this session's bridge.
25
+ # Exit 0 on success/report; exit 1 when the verb is blocked (held elsewhere).
26
+
27
+ require "json"
28
+ require_relative "lib/bridge"
29
+ require_relative "lib/lock"
30
+
31
+ def usage!
32
+ warn "usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim> " \
33
+ "[--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]"
34
+ exit 1
35
+ end
36
+
37
+ verb = ARGV.shift
38
+ usage! unless %w[status fix release reclaim delegate claim release-claim].include?(verb)
39
+
40
+ opts = {}
41
+ until ARGV.empty?
42
+ case (flag = ARGV.shift)
43
+ when "--intent-dir" then opts[:dir] = ARGV.shift
44
+ when "--session" then opts[:session] = ARGV.shift
45
+ when "--delegate" then opts[:delegate] = ARGV.shift
46
+ when "--artifact" then opts[:artifact] = ARGV.shift
47
+ else
48
+ warn "unknown flag #{flag}"
49
+ usage!
50
+ end
51
+ end
52
+
53
+ session = opts[:session]
54
+ session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.strip.empty?
55
+
56
+ dir = opts[:dir]
57
+ if dir.nil? || dir.strip.empty?
58
+ bridge = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
59
+ dir = Bridge.bridge_intent_dir(bridge)
60
+ end
61
+ if dir.nil?
62
+ warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
63
+ exit 1
64
+ end
65
+ dir = File.expand_path(dir)
66
+
67
+ intent_id = Bridge.intent_id_from_dir(dir)
68
+ store = File.dirname(dir)
69
+ name = File.basename(dir)
70
+ key = Bridge.resolve_session(session, intent_id: intent_id, store: store)
71
+
72
+ case verb
73
+ when "status"
74
+ lock = Lock.read(dir)
75
+ bridge = Bridge.read(key)
76
+ report = {
77
+ "intent_dir" => dir,
78
+ "session" => key,
79
+ "lock" => lock,
80
+ "lock_fresh" => Lock.fresh?(dir),
81
+ "lock_corrupt" => Lock.corrupt?(dir),
82
+ "bridge_present" => !bridge.nil?,
83
+ "agreement" => (lock && bridge) ?
84
+ (lock["owner_session"] == bridge.dig("lock", "owner_session")) : nil,
85
+ "claims" => Claim.claims_status(dir),
86
+ }
87
+ puts JSON.pretty_generate(report)
88
+ when "fix"
89
+ report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
90
+ store: store, name: name)
91
+ puts JSON.pretty_generate(report)
92
+ unless report["status"] == "repaired"
93
+ warn "plastic-lock: #{report['status']} by #{report['owner']}" \
94
+ "#{report['hint'] ? " (#{report['hint']})" : ''}"
95
+ exit 1
96
+ end
97
+ when "release"
98
+ result = Lock.release(dir, session: key)
99
+ if result == :not_owner
100
+ warn "plastic-lock: not the owner; run plastic-lock status"
101
+ exit 1
102
+ end
103
+ data = Bridge.read(key)
104
+ if data
105
+ data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
106
+ "host" => nil, "type" => nil, "delegates" => [] }
107
+ Bridge.write(key, data)
108
+ end
109
+ puts "released (#{result})"
110
+ when "reclaim"
111
+ status, lock_data = Lock.takeover(dir, session: key)
112
+ if status == :fresh
113
+ warn "plastic-lock: lock is FRESH and held by #{lock_data['owner_session']}; " \
114
+ "back off (no silent reclaim)"
115
+ exit 1
116
+ end
117
+ report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
118
+ store: store, name: name)
119
+ puts JSON.pretty_generate(report)
120
+ when "delegate"
121
+ usage! if opts[:delegate].nil?
122
+ ok = Lock.add_delegate(dir, delegate: opts[:delegate], session: key)
123
+ unless ok
124
+ warn "plastic-lock: only the lock owner may delegate; run plastic-lock status"
125
+ exit 1
126
+ end
127
+ puts "delegated #{opts[:delegate]} under #{key}"
128
+ when "claim"
129
+ usage! if opts[:artifact].nil?
130
+ status, data = Claim.acquire_claim(dir, opts[:artifact], session: key,
131
+ delegate: opts[:delegate])
132
+ case status
133
+ when :acquired
134
+ puts "claimed #{opts[:artifact]} under #{key}"
135
+ when :held
136
+ warn "plastic-lock: #{opts[:artifact]} is claimed by " \
137
+ "#{data['owner_session']} since #{data['acquired_at']}; back off"
138
+ exit 1
139
+ when :stale
140
+ old = data && data["owner_session"]
141
+ Claim.release_claim(dir, opts[:artifact], session: key, force: true)
142
+ status2, = Claim.acquire_claim(dir, opts[:artifact], session: key,
143
+ delegate: opts[:delegate])
144
+ if status2 == :acquired
145
+ warn "plastic-lock: took over stale claim on #{opts[:artifact]} from #{old}"
146
+ puts "claimed #{opts[:artifact]} under #{key}"
147
+ else
148
+ warn "plastic-lock: could not take over #{opts[:artifact]} (#{status2})"
149
+ exit 1
150
+ end
151
+ when :corrupt
152
+ Claim.release_claim(dir, opts[:artifact], session: key, force: true)
153
+ Claim.acquire_claim(dir, opts[:artifact], session: key, delegate: opts[:delegate])
154
+ puts "claimed #{opts[:artifact]} under #{key} (repaired corrupt claim)"
155
+ end
156
+ when "release-claim"
157
+ usage! if opts[:artifact].nil?
158
+ result = Claim.release_claim(dir, opts[:artifact], session: key)
159
+ if result == :not_owner
160
+ warn "plastic-lock: not the claim owner; run plastic-lock status"
161
+ exit 1
162
+ end
163
+ puts "released-claim #{opts[:artifact]} (#{result})"
164
+ end
@@ -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