@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
@@ -6,6 +6,8 @@ require "yaml"
6
6
  require "fileutils"
7
7
  require "digest"
8
8
  require "time"
9
+ require_relative "hook_registry"
10
+ require_relative "agent_models"
9
11
 
10
12
  # Shared installer machinery, instantiable with injected package root / store / agent
11
13
  # map so the verb scripts (install/update/uninstall/versions) and their tests can run
@@ -203,16 +205,36 @@ class InstallerCore
203
205
  "scripts/hook-continue" => "scripts/hook-continue",
204
206
  "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
205
207
  "scripts/hook-gate-check" => "scripts/hook-gate-check",
208
+ "scripts/hook-savepoint-pre" => "scripts/hook-savepoint-pre",
206
209
  "scripts/hook-qmd-search" => "scripts/hook-qmd-search",
207
210
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
211
+ "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
212
+ "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
208
213
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
214
+ "scripts/hook-lock-gate" => "scripts/hook-lock-gate",
209
215
  "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
216
+ "scripts/hook-retrieval-gate" => "scripts/hook-retrieval-gate",
217
+ "scripts/lib/retrieval_gate.rb" => "scripts/lib/retrieval_gate.rb",
210
218
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
211
219
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
220
+ "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
221
+ "scripts/plastic-lock" => "scripts/plastic-lock",
222
+ "scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
223
+ "scripts/agent-report" => "scripts/agent-report",
224
+ "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
225
+ "scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
212
226
  "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
213
227
  "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
214
228
  "scripts/qmd-sync" => "scripts/qmd-sync",
215
229
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
230
+ "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
231
+ "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
232
+ "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
233
+ "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
234
+ "scripts/lib/link_suggestions.rb" => "scripts/lib/link_suggestions.rb",
235
+ "scripts/project-links" => "scripts/project-links",
236
+ "scripts/link-suggest" => "scripts/link-suggest",
237
+ "scripts/rebuild-graph" => "scripts/rebuild-graph",
216
238
  "scripts/validate-intent" => "scripts/validate-intent",
217
239
  "scripts/new-intent" => "scripts/new-intent",
218
240
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
@@ -221,6 +243,7 @@ class InstallerCore
221
243
  "templates/plan.md" => "templates/plan.md",
222
244
  "templates/checklist.md" => "templates/checklist.md",
223
245
  "templates/outcome.md" => "templates/outcome.md",
246
+ "templates/revisions.md" => "templates/revisions.md",
224
247
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
225
248
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
226
249
  "scripts/provision-project-store" => "scripts/provision-project-store",
@@ -370,7 +393,7 @@ class InstallerCore
370
393
  installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
371
394
 
372
395
  # Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
373
- installed += install_agents(File.join(config[:dir], "agents"))
396
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
374
397
 
375
398
  # Write VERSION
376
399
  version_file = File.join(plastic_dir, "VERSION")
@@ -395,7 +418,7 @@ class InstallerCore
395
418
  installed = []
396
419
  skills_source = File.join(package_root, "skills")
397
420
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
398
- installed += install_agents(File.join(config[:dir], "agents"))
421
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
399
422
 
400
423
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
401
424
  { agent: config[:name], success: true, files: installed.size }
@@ -405,7 +428,7 @@ class InstallerCore
405
428
  installed = []
406
429
  skills_source = File.join(package_root, "skills")
407
430
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
408
- installed += install_agents(File.join(config[:dir], "agents"))
431
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
409
432
 
410
433
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
411
434
  { agent: config[:name], success: true, files: installed.size }
@@ -438,18 +461,51 @@ class InstallerCore
438
461
  # equivalents). Returns the installed destination paths so callers can append
439
462
  # them to `installed` before write_manifest (manifest + prune are then automatic).
440
463
  # No-op safe: returns [] when the package has no agents dir or it is empty.
441
- def install_agents(agents_root)
464
+ def install_agents(agents_root, models: {})
442
465
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
443
466
  return [] if sources.empty?
444
467
 
445
468
  FileUtils.mkdir_p(agents_root)
446
469
  sources.map do |src|
447
470
  dest = File.join(agents_root, File.basename(src))
448
- FileUtils.cp(src, dest)
471
+ basename = File.basename(src, ".md")
472
+ override = models[basename]
473
+ if override
474
+ File.write(dest, rewrite_model_line(File.read(src), override))
475
+ else
476
+ FileUtils.cp(src, dest)
477
+ end
449
478
  dest
450
479
  end
451
480
  end
452
481
 
482
+ # Rewrite the single top-level `model:` line in a YAML frontmatter block.
483
+ # Only the frontmatter (between the first two `---` fences) is touched.
484
+ def rewrite_model_line(content, model)
485
+ content.sub(/^model:[^\n]*$/, "model: #{model}")
486
+ end
487
+
488
+ # Resolve per-agent model overrides for this install: project config (when a
489
+ # project dir is known) overlaid on global config. Defaults are NOT included,
490
+ # so unconfigured agents keep their shipped frontmatter.
491
+ def agent_model_overrides(project_dir = nil)
492
+ global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
493
+ project_config =
494
+ if project_dir
495
+ load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml"))
496
+ else
497
+ {}
498
+ end
499
+ AgentModels.override_map(project_config: project_config, global_config: global_config)
500
+ end
501
+
502
+ def load_config_yaml(path)
503
+ return {} unless File.exist?(path)
504
+ YAML.safe_load(File.read(path)) || {}
505
+ rescue StandardError
506
+ {}
507
+ end
508
+
453
509
  # --- Legacy plugin migration ---
454
510
 
455
511
  # Earlier versions registered Plastic as a local marketplace plugin
@@ -524,55 +580,9 @@ class InstallerCore
524
580
 
525
581
  purge_stale_plastic_hooks(hooks)
526
582
 
527
- plastic_hooks = {
528
- "SessionStart" => {
529
- "matcher" => "",
530
- "hooks" => [
531
- { "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
532
- { "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
533
- ],
534
- },
535
- "PreCompact" => {
536
- "matcher" => "",
537
- "hooks" => [
538
- { "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
539
- ],
540
- },
541
- # PreToolUse carries TWO plastic groups with distinct matchers: the
542
- # code-gate (Write|Edit|NotebookEdit) and the create-gate (Write only, intent
543
- # 60b). A single group cannot carry two matchers, so this event maps to a
544
- # LIST of groups; the merge loop appends each (idempotent because the purge
545
- # pass removes all prior plastic groups first).
546
- "PreToolUse" => [
547
- {
548
- "matcher" => "Write|Edit|NotebookEdit",
549
- "hooks" => [
550
- { "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
551
- ],
552
- },
553
- {
554
- "matcher" => "Write",
555
- "hooks" => [
556
- { "type" => "command", "command" => "#{hook_dir}/plastic-create-gate", "statusMessage" => "Checking create gate..." },
557
- ],
558
- },
559
- ],
560
- "PostToolUse" => {
561
- "matcher" => "Write|Edit",
562
- "hooks" => [
563
- { "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
564
- ],
565
- },
566
- "UserPromptSubmit" => {
567
- "matcher" => "",
568
- "hooks" => [
569
- { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
570
- { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
571
- { "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
572
- { "type" => "command", "command" => "#{hook_dir}/plastic-qmd-search", "statusMessage" => "Searching QMD..." },
573
- ],
574
- },
575
- }
583
+ # Single source of truth (intent 108, D7): registrations live in
584
+ # HookRegistry; this merge only translates them into settings.json.
585
+ plastic_hooks = HookRegistry.claude_settings_hooks(hook_dir: hook_dir)
576
586
 
577
587
  plastic_hooks.each do |event, group|
578
588
  hooks[event] ||= []
@@ -0,0 +1,319 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # LinkSuggestions - support links decided by CONTEXT INFLUENCE (intent 91, D7), not
5
+ # by shared files, shared symbols, or a topic-similarity score. A script cannot judge
6
+ # whether one intent's context influenced another (that is reasoning over meaning), so
7
+ # this helper does NOT grade. It only:
8
+ #
9
+ # - gather: DISCOVERY. Find candidate intents (via an injected candidate-finder)
10
+ # and surface each candidate's `## Intent` + `## Context` as the
11
+ # EVIDENCE an agent reads to judge influence.
12
+ # - record_edge: record a CONFIRMED `sources`/`chain` frontmatter edge AND append a
13
+ # dated line to the subject intent file's `## Insights` section
14
+ # carrying (utc, target, edge, rating, reason).
15
+ # Append-only, frontmatter block only, never a `## Links` line,
16
+ # never a delete. A no-op without confirm: true.
17
+ # - drift: flag any `## Links` wikilink with no matching frontmatter edge.
18
+ #
19
+ # Two systems frame this:
20
+ # - System for Brain: links are tiered by context influence (sources = foundational
21
+ # context that shaped creation; chain = the genuinely delivery-moving context, a
22
+ # HIGH bar; tags = loose theme grouping, not a link). The influence judgement is
23
+ # made by an agent, not here.
24
+ # - System for Work (Convention over Configuration): `## Links` is a derived view of
25
+ # `sources`/`chain`. This helper never authors a `## Links` line and never deletes.
26
+ #
27
+ # Design rules: all collaborators are injected via the constructor (the store dir, a
28
+ # filesystem reader, AND a candidate-finder). No eval, no ENV / global config seam.
29
+ # Reuses IntentValidator.parse_frontmatter; does NOT touch LinksProjection /
30
+ # LinksSection / project-links / doctor.
31
+
32
+ require "time"
33
+ require_relative "intent_validator"
34
+ require_relative "insights"
35
+
36
+ class LinkSuggestions
37
+ # One discovery candidate plus the evidence an agent reads to judge influence.
38
+ Candidate = Struct.new(:id, :basename, :label, :intent, :context, keyword_init: true)
39
+
40
+ # A drift finding: a `## Links` ref with no matching `sources`/`chain` edge.
41
+ Drift = Struct.new(:ref, :detail, keyword_init: true)
42
+
43
+ RATINGS = %w[high medium low].freeze
44
+
45
+ # A minimal default filesystem reader. Injected so tests can substitute an
46
+ # in-memory map; the production path reads real files. No global state.
47
+ class DiskReader
48
+ def directory?(path)
49
+ File.directory?(path)
50
+ end
51
+
52
+ def children(path)
53
+ Dir.children(path)
54
+ end
55
+
56
+ def exist?(path)
57
+ File.exist?(path)
58
+ end
59
+
60
+ def read(path)
61
+ File.read(path)
62
+ end
63
+
64
+ def write(path, content)
65
+ File.write(path, content)
66
+ end
67
+ end
68
+
69
+ # Default candidate-finder: a cheap discovery net (NOT a grade) over the loaded
70
+ # nodes - candidates that share a non-project tag, share a `sources` parent/family,
71
+ # or sit at an adjacent id. The CLI may inject a QMD-backed finder instead. Either
72
+ # way this is DISCOVERY ONLY; influence is judged later by an agent.
73
+ class FamilyTagFinder
74
+ def call(subject_id, nodes)
75
+ subject = nodes[subject_id]
76
+ return [] unless subject
77
+
78
+ nodes.keys.select do |other_id|
79
+ next false if other_id == subject_id
80
+
81
+ other = nodes[other_id]
82
+ shares_tag?(subject, other) || shares_family?(subject, other) ||
83
+ adjacent_id?(subject_id, other_id)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def shares_tag?(subject, other)
90
+ !(link_tags(subject) & link_tags(other)).empty?
91
+ end
92
+
93
+ def link_tags(node)
94
+ node[:tags].reject { |t| t.start_with?("project-") }
95
+ end
96
+
97
+ def shares_family?(subject, other)
98
+ return true unless (subject[:sources] & other[:sources]).empty?
99
+
100
+ subject[:sources].include?(other[:id]) || other[:sources].include?(subject[:id])
101
+ end
102
+
103
+ # Adjacent ids (discovery hint only): equal non-numeric prefix with integers
104
+ # differing by 1 (9<->10, 99<->100, 90<->91), or a letter successor at the same
105
+ # depth (66a<->66b). This is NOT a grade; it only widens the candidate net.
106
+ def adjacent_id?(a, b)
107
+ return false if a == b || a.empty? || b.empty?
108
+
109
+ ma = a.match(/\A(.*?)(\d+)\z/)
110
+ mb = b.match(/\A(.*?)(\d+)\z/)
111
+ return (ma[2].to_i - mb[2].to_i).abs == 1 if ma && mb && ma[1] == mb[1]
112
+
113
+ return false unless a.length == b.length
114
+
115
+ a[0..-2] == b[0..-2] && a[-1].match?(/[a-z]/) && b[-1].match?(/[a-z]/) &&
116
+ (a[-1].succ == b[-1] || b[-1].succ == a[-1])
117
+ end
118
+ end
119
+
120
+ # store_dir - the directory holding `id--slug/` intent folders for ONE store.
121
+ # reader - injected filesystem collaborator (DiskReader by default).
122
+ # finder - injected candidate-finder responding to #call(subject_id, nodes).
123
+ def initialize(store_dir:, reader: DiskReader.new, finder: FamilyTagFinder.new)
124
+ @store_dir = store_dir
125
+ @reader = reader
126
+ @finder = finder
127
+ end
128
+
129
+ attr_reader :store_dir, :reader, :finder
130
+
131
+ # Load every intent in the store as a node Hash keyed by id:
132
+ # { id => { id:, basename:, label:, path:, dir:, sources:[], chain:[], tags:[],
133
+ # links_refs:[], intent:, context: } }
134
+ def load_nodes
135
+ nodes = {}
136
+ return nodes unless reader.directory?(store_dir)
137
+
138
+ reader.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
139
+ dir = File.join(store_dir, entry)
140
+ next unless reader.directory?(dir)
141
+
142
+ md = File.join(dir, "#{entry}.md")
143
+ next unless reader.exist?(md)
144
+
145
+ content = reader.read(md)
146
+ fm = IntentValidator.parse_frontmatter_text(content)
147
+ next unless fm.is_a?(Hash) && fm["id"]
148
+
149
+ id = fm["id"].to_s
150
+ body = IntentValidator.body_of(content)
151
+ nodes[id] = {
152
+ id: id,
153
+ basename: entry,
154
+ label: fm["intent"].to_s.strip,
155
+ path: md,
156
+ dir: dir,
157
+ sources: Array(fm["sources"]).map(&:to_s),
158
+ chain: Array(fm["chain"]).map(&:to_s),
159
+ tags: Array(fm["tags"]).map(&:to_s),
160
+ links_refs: links_refs(body),
161
+ intent: section_text(body, "Intent"),
162
+ context: section_text(body, "Context"),
163
+ }
164
+ end
165
+ nodes
166
+ end
167
+
168
+ # DISCOVERY. The candidate intents for `subject_id`, each carrying its Intent +
169
+ # Context as the evidence an agent reads to judge influence. No grading. Sorted by
170
+ # natural id order for stable output.
171
+ def gather(subject_id, nodes: load_nodes)
172
+ return [] unless nodes.key?(subject_id)
173
+
174
+ ids = finder.call(subject_id, nodes)
175
+ ids.uniq.sort_by { |id| natural_key(id) }.filter_map do |id|
176
+ node = nodes[id]
177
+ next unless node
178
+
179
+ Candidate.new(id: id, basename: node[:basename], label: node[:label],
180
+ intent: node[:intent], context: node[:context])
181
+ end
182
+ end
183
+
184
+ # Record a single CONFIRMED edge from `subject_id` to `target_id`:
185
+ # 1. append `target_id` to the subject's frontmatter `sources` or `chain`;
186
+ # 2. append a dated line to the subject's `## Insights` section capturing
187
+ # {utc, target, edge, rating, reason}.
188
+ # Append-only, frontmatter block only. NEVER writes a `## Links` line, NEVER
189
+ # deletes. A no-op (returns false) without confirm: true, so a default run mutates
190
+ # nothing. Returns true when it wrote, false when it declined or the edge existed.
191
+ def record_edge(subject_id, target_id, edge:, rating: nil, reason: nil,
192
+ confirm: false, now: Time.now, nodes: load_nodes)
193
+ return false unless confirm
194
+ return false unless %i[sources chain].include?(edge)
195
+
196
+ subject = nodes[subject_id]
197
+ return false unless subject
198
+ return false if subject[edge].include?(target_id)
199
+
200
+ content = reader.read(subject[:path])
201
+ updated = add_frontmatter_ref(content, edge.to_s, target_id)
202
+ return false if updated == content
203
+
204
+ final = append_link_insight(updated, target_id, edge, rating, reason, now)
205
+ reader.write(subject[:path], final)
206
+ true
207
+ end
208
+
209
+ # DRIFT. The `## Links` refs on `subject_id` with no matching `sources`/`chain`
210
+ # frontmatter edge behind them. Fence-skipping is honored so a `[[id]]` inside an
211
+ # example code block is not flagged.
212
+ def drift(subject_id, nodes: load_nodes)
213
+ subject = nodes[subject_id]
214
+ return [] unless subject
215
+
216
+ edges = (subject[:sources] + subject[:chain]).map { |r| bare_ref(r) }
217
+ subject[:links_refs].reject { |r| edges.include?(bare_ref(r)) }.map do |ref|
218
+ Drift.new(ref: ref,
219
+ detail: "`## Links` references #{ref} with no sources/chain edge behind it")
220
+ end
221
+ end
222
+
223
+ private
224
+
225
+ # Insert one dated link-decision line at the bottom of the subject intent file's
226
+ # `## Insights` section (96 D3: link rationale lives IN the intent file, never a side
227
+ # file). Reuses the blessed Insights writer (intent 82) for section insertion; carries
228
+ # the five fields (utc, target, edge, rating, reason) as a readable line under the 82
229
+ # exact-timestamp prefix. Returns the augmented content for the single caller write.
230
+ def append_link_insight(content, target_id, edge, rating, reason, now)
231
+ prefix = "#{now.utc.iso8601} · Link · link-suggest"
232
+ text = "#{edge} edge to #{target_id} (rating #{rating || "-"}): #{reason || "-"}"
233
+ Insights.with_entry(content, "#{prefix} — #{text}")
234
+ end
235
+
236
+ # Natural sort key for ids so 2 sorts before 10 and 14a groups with 14.
237
+ def natural_key(id)
238
+ id.scan(/\d+|[a-z]+/).map { |part| part.match?(/\d/) ? [0, part.to_i, ""] : [1, 0, part] }
239
+ end
240
+
241
+ # Strip a `store:` prefix from a ref so a cross-store and same-store form compare.
242
+ def bare_ref(ref)
243
+ ref.to_s.include?(":") ? ref.to_s.split(":", 2).last : ref.to_s
244
+ end
245
+
246
+ # Extract bare-id wikilink refs from a body's `## Links` section. Matches
247
+ # `[[<id>--<slug>|...]]` and `[[<store>:<id>--<slug>|...]]`, yielding the bare id.
248
+ # Reads only the Links section so example fences elsewhere are not scanned.
249
+ def links_refs(body)
250
+ section = section_text(body, "Links")
251
+ return [] if section.empty?
252
+
253
+ section.scan(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/).filter_map do |match|
254
+ t = bare_ref(match.first.strip)
255
+ id = t.split("--", 2).first
256
+ id unless id.nil? || id.empty?
257
+ end.uniq
258
+ end
259
+
260
+ # The text under the first `## <heading>` heading, up to the next `## ` heading. A
261
+ # deliberately small reader since we only need the section content. Fence lines are
262
+ # skipped so a `## Links` (or any heading) inside an example block is ignored.
263
+ def section_text(body, heading)
264
+ lines = body.to_s.lines
265
+ out = []
266
+ capture = false
267
+ in_fence = false
268
+ lines.each do |line|
269
+ stripped = line.strip
270
+ if stripped.start_with?("```")
271
+ in_fence = !in_fence
272
+ next
273
+ end
274
+ next if in_fence
275
+
276
+ if stripped == "## #{heading}"
277
+ capture = true
278
+ next
279
+ end
280
+ break if capture && stripped.start_with?("## ")
281
+
282
+ out << line if capture
283
+ end
284
+ out.join.strip
285
+ end
286
+
287
+ # Append a ref to a frontmatter array (`sources` or `chain`), creating the key if
288
+ # absent. Touches ONLY the frontmatter block; the body (including `## Links`) is
289
+ # left byte-identical. Inline-flow arrays (`key: ["a", "b"]`) are extended in place;
290
+ # an absent key is inserted before the closing `---`. Never deletes.
291
+ def add_frontmatter_ref(content, key, ref)
292
+ return content unless content.start_with?("---")
293
+
294
+ parts = content.split("---", 3)
295
+ return content if parts.length < 3
296
+
297
+ fm = parts[1]
298
+ line_re = /^#{Regexp.escape(key)}:\s*(.*)$/
299
+ if fm =~ line_re
300
+ current = Regexp.last_match(1).strip
301
+ new_line = extend_flow_array(key, current, ref)
302
+ fm = fm.sub(line_re, new_line)
303
+ else
304
+ fm = fm.rstrip + "\n#{key}: [\"#{ref}\"]\n"
305
+ end
306
+ "---#{fm}---#{parts[2]}"
307
+ end
308
+
309
+ def extend_flow_array(key, current, ref)
310
+ if current.empty? || current == "[]"
311
+ %(#{key}: ["#{ref}"])
312
+ elsif current.start_with?("[") && current.end_with?("]")
313
+ inner = current[1..-2].strip
314
+ inner.empty? ? %(#{key}: ["#{ref}"]) : %(#{key}: [#{inner}, "#{ref}"])
315
+ else
316
+ %(#{key}: ["#{ref}"])
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,160 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # LinksProjection — pure logic that projects one intent's sources/chain graph into
5
+ # the canonical I5 `## Links` section text (intent 72). Mirrors the pure-module
6
+ # style of GraphRebuild: module-function helpers, no file IO, no eval, no
7
+ # ENV/global state. The IO shell (scripts/project-links) and doctor build the
8
+ # cross-store resolver and feed it here.
9
+ #
10
+ # Pinned canonical projection (the human's format call):
11
+ # 1. ORDERING (load-bearing): ALL sources first, in frontmatter order, THEN all
12
+ # chain, in frontmatter order. Sources can NEVER appear at the end. No group
13
+ # headings, no per-entry source/chain tags: the ordering carries the meaning.
14
+ # 2. ENTRY SHAPE: one list item `- [[<id>--<slug>|<target's full intent: text>]]`,
15
+ # where the wikilink TARGET is the target intent's resolvable `id--slug` file
16
+ # basename (so it clicks through in Obsidian) and the LABEL is the target's
17
+ # full `intent:` frontmatter text, whitespace-trimmed.
18
+ # 3. CROSS-STORE: a cross-store target renders
19
+ # `- [[<store>:<id>--<slug>|<target's full intent: text>]]`.
20
+ # 4. RESOLVER MISS: a ref that resolves to no intent raises UnresolvedRef. No
21
+ # bare-id, slug-less, or guessed link is ever emitted.
22
+ # 5. EMPTY-STATE: empty sources AND chain yields the heading plus a single
23
+ # explanatory comment.
24
+ module LinksProjection
25
+ module_function
26
+
27
+ HEADING = "## Links"
28
+ EMPTY_COMMENT = "<!-- No sources or chain; this intent has no graph edges to project. -->"
29
+
30
+ # Raised when a sources/chain ref resolves to no intent. Carries the offending
31
+ # ref so the IO shell can report it per-intent and skip the write.
32
+ class UnresolvedRef < StandardError
33
+ attr_reader :ref
34
+
35
+ def initialize(ref)
36
+ @ref = ref
37
+ super("unresolved sources/chain ref: #{ref.inspect}")
38
+ end
39
+ end
40
+
41
+ # PURE. Build the canonical `## Links` section text for one intent.
42
+ #
43
+ # sources — array of id / `store:id` ref strings, in frontmatter order
44
+ # chain — array of id / `store:id` ref strings, in frontmatter order
45
+ # resolve — a callable (`->(ref) { ... }`) mapping ONE ref to a Hash like
46
+ # { target: "<id>--<slug>", label: "<full intent: text>" } or
47
+ # { target: "<store>:<id>--<slug>", label: "<full intent: text>" }.
48
+ # Returning nil (or a Hash lacking :target) signals no target and
49
+ # raises UnresolvedRef. Keeping resolution injected keeps this module
50
+ # pure and hermetically testable with in-memory maps.
51
+ #
52
+ # Returns the full section text: the `## Links` heading line, one entry line per
53
+ # ref (sources first, then chain), and a single trailing newline. The empty case
54
+ # returns the heading + the empty-state comment + a single trailing newline.
55
+ def section(sources:, chain:, resolve:)
56
+ src = Array(sources).map(&:to_s)
57
+ chn = Array(chain).map(&:to_s)
58
+
59
+ # Resolve EVERY ref to its { target:, label: } first, then dedup by the RESOLVED
60
+ # target (not the raw ref string). This is load-bearing: the same intent may be
61
+ # referenced as a bare id in one group and as `store:id` in another (or via a
62
+ # relocation), which dedups identically only AFTER resolution. Sources win
63
+ # (formative edge), and frontmatter order is preserved within each group.
64
+ seen = {}
65
+ rendered = []
66
+ src.each { |ref| add_entry(ref, resolve, seen, rendered) }
67
+ chn.each { |ref| add_entry(ref, resolve, seen, rendered) }
68
+
69
+ return empty_section if rendered.empty?
70
+
71
+ (["#{HEADING}\n"] + rendered.map { |line| "#{line}\n" }).join
72
+ end
73
+
74
+ # PURE. The canonical empty-state section: heading + the single comment line.
75
+ def empty_section
76
+ "#{HEADING}\n#{EMPTY_COMMENT}\n"
77
+ end
78
+
79
+ # Resolve `ref`, render its entry, and append it to `rendered` UNLESS its resolved
80
+ # target was already emitted (dedup by resolved target, first-seen wins so sources
81
+ # precede chain). Mutates `seen` and `rendered`. Raises UnresolvedRef on a miss.
82
+ def add_entry(ref, resolve, seen, rendered)
83
+ target, label = resolve_entry(ref, resolve)
84
+ return if seen.key?(target)
85
+
86
+ seen[target] = true
87
+ rendered << "- [[#{target}|#{label}]]"
88
+ end
89
+
90
+ # PURE. Resolve one ref to [target, label]. Raises UnresolvedRef when the
91
+ # resolver returns nothing usable.
92
+ def resolve_entry(ref, resolve)
93
+ resolved = resolve.call(ref)
94
+ target = resolved.is_a?(Hash) ? resolved[:target] || resolved["target"] : nil
95
+ raise UnresolvedRef, ref if target.nil? || target.to_s.strip.empty?
96
+
97
+ label = (resolved[:label] || resolved["label"]).to_s.strip
98
+ [target.to_s, label]
99
+ end
100
+
101
+ # PURE. Render one entry line `- [[<target>|<label>]]` from a single ref. Kept for
102
+ # callers/tests that render one entry; #section uses add_entry for dedup.
103
+ def entry(ref, resolve)
104
+ target, label = resolve_entry(ref, resolve)
105
+ "- [[#{target}|#{label}]]"
106
+ end
107
+
108
+ # PURE. Resolve ONE sources/chain ref to its `{ target:, label: }` projection,
109
+ # given the in-memory cross-store maps. This is the single resolver definition
110
+ # shared by the IO shell (scripts/project-links) and the doctor check, so the two
111
+ # can never diverge.
112
+ #
113
+ # ref — "40" (same-store bare id) or "knowdb:1" (cross-store)
114
+ # referer_store — store_key of the intent carrying the ref ("global" / "project:<slug>")
115
+ # relocation_map — from GraphRebuild.build_relocation_map (spans all stores)
116
+ # store_index — { store_key => [bare ids present] } (spans all stores)
117
+ # node_index — { store_key => { id => { basename:, label: } } } (spans all stores)
118
+ #
119
+ # Returns { target:, label: } (target is `<id>--<slug>` for a same-store id, or
120
+ # `<slug>:<id>--<slug>` for a cross-store one), or nil when the ref resolves to no
121
+ # live intent (which makes #section / #entry raise UnresolvedRef).
122
+ #
123
+ # Uses GraphRebuild.resolve_ref so a relocation always wins over a coincidentally
124
+ # reused id (the `global:24` impostor hazard), exactly as the frontmatter rebuild
125
+ # and the cross-store doctor check do.
126
+ def resolve_ref_projection(ref, referer_store:, relocation_map:, store_index:, node_index:)
127
+ require_relative "graph_rebuild"
128
+
129
+ res = GraphRebuild.resolve_ref(ref, referer_store: referer_store,
130
+ relocation_map: relocation_map,
131
+ store_index: store_index)
132
+ case res[:status]
133
+ when :same_store
134
+ node = (node_index[referer_store] || {})[res[:id]]
135
+ return nil if node.nil?
136
+
137
+ { target: node[:basename], label: node[:label] }
138
+ when :cross_store
139
+ slug, bare = res[:ref].split(":", 2)
140
+ target_key = canonical_store_key(slug, store_index)
141
+ node = (node_index[target_key] || {})[bare]
142
+ return nil if node.nil?
143
+
144
+ { target: "#{slug}:#{node[:basename]}", label: node[:label] }
145
+ else # :dead
146
+ nil
147
+ end
148
+ end
149
+
150
+ # Map a ref store slug ("global", "knowdb", "plastic") to a node_index/store_index
151
+ # key ("global", "project:knowdb", "project:plastic"). Mirrors
152
+ # GraphRebuild.canonical_store_key's intent for the node_index keyspace.
153
+ def canonical_store_key(slug, store_index)
154
+ return "global" if slug == "global"
155
+ return slug if (store_index || {}).key?(slug)
156
+
157
+ projected = "project:#{slug}"
158
+ (store_index || {}).key?(projected) ? projected : slug
159
+ end
160
+ end