@zalom/plastic 1.0.0-beta.8 → 1.0.0

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 (116) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +407 -84
  3. package/README.md +99 -82
  4. package/agents/plastic-brainstorming.md +12 -9
  5. package/agents/plastic-enforcer.md +52 -12
  6. package/agents/plastic-executor.md +10 -10
  7. package/agents/plastic-future-intent-researcher.md +4 -8
  8. package/agents/plastic-intent-curator.md +12 -14
  9. package/agents/plastic-intent-discovery.md +35 -0
  10. package/agents/plastic-planner.md +15 -10
  11. package/agents/plastic-spec-specialist.md +15 -10
  12. package/bin/plastic.js +7 -3
  13. package/hooks/code-gate +10 -1
  14. package/hooks/hooks.json +28 -3
  15. package/hooks/lock-gate +21 -0
  16. package/hooks/retrieval-gate +10 -0
  17. package/hooks/savepoint-pre +10 -0
  18. package/hooks/statusline +48 -6
  19. package/package.json +2 -1
  20. package/scripts/agent-report +21 -0
  21. package/scripts/dashboard.rb +98 -23
  22. package/scripts/doctor.rb +404 -5
  23. package/scripts/hook-auto-arm +1 -1
  24. package/scripts/hook-bash-gate +22 -4
  25. package/scripts/hook-code-gate +11 -6
  26. package/scripts/hook-continue +17 -0
  27. package/scripts/hook-create-gate +45 -9
  28. package/scripts/hook-gate-check +24 -23
  29. package/scripts/hook-lock-gate +83 -0
  30. package/scripts/hook-retrieval-gate +148 -0
  31. package/scripts/hook-savepoint-pre +32 -0
  32. package/scripts/hook-session-start +1 -1
  33. package/scripts/insight-append +51 -0
  34. package/scripts/install.rb +44 -7
  35. package/scripts/lib/agent_models.rb +43 -0
  36. package/scripts/lib/bridge.rb +924 -84
  37. package/scripts/lib/dashboard_banner.rb +42 -0
  38. package/scripts/lib/frontmatter_writer.rb +130 -0
  39. package/scripts/lib/graph_rebuild.rb +328 -0
  40. package/scripts/lib/hook_registry.rb +95 -0
  41. package/scripts/lib/insights.rb +86 -0
  42. package/scripts/lib/installer_core.rb +104 -60
  43. package/scripts/lib/link_suggestions.rb +319 -0
  44. package/scripts/lib/links_projection.rb +160 -0
  45. package/scripts/lib/links_section.rb +207 -0
  46. package/scripts/lib/lock.rb +375 -0
  47. package/scripts/lib/power_tools.rb +22 -21
  48. package/scripts/lib/preflight.rb +79 -0
  49. package/scripts/lib/qmd_sync.rb +15 -0
  50. package/scripts/lib/retrieval_gate.rb +211 -0
  51. package/scripts/lib/worktree.rb +384 -0
  52. package/scripts/link-suggest +213 -0
  53. package/scripts/new-intent +138 -29
  54. package/scripts/plastic-lock +164 -0
  55. package/scripts/project-links +287 -0
  56. package/scripts/read-config +4 -0
  57. package/scripts/rebuild-graph +244 -0
  58. package/scripts/spawn-preamble +11 -3
  59. package/scripts/update.rb +16 -7
  60. package/skills/auto/SKILL.md +126 -19
  61. package/skills/auto/references/agent-architecture.md +7 -4
  62. package/skills/auto/references/agent-report-contract.md +36 -1
  63. package/skills/auto/references/end-tail.md +56 -0
  64. package/skills/auto/references/human-report-contract.md +55 -0
  65. package/skills/auto/references/tiers.md +77 -0
  66. package/skills/brainstorming/SKILL.md +7 -34
  67. package/skills/brainstorming/references/design-principles.md +49 -0
  68. package/skills/continuing/SKILL.md +26 -7
  69. package/skills/creating-intent/SKILL.md +13 -28
  70. package/skills/creating-intent/references/lifecycle.md +9 -4
  71. package/skills/creating-project/SKILL.md +11 -74
  72. package/skills/creating-project/references/project-scaffolding.md +97 -0
  73. package/skills/creating-skills/SKILL.md +65 -0
  74. package/skills/creating-skills/evals/evals.json +108 -0
  75. package/skills/creating-skills/references/agents.md +168 -0
  76. package/skills/creating-skills/references/evals.md +41 -0
  77. package/skills/creating-skills/references/hooks.md +248 -0
  78. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  79. package/skills/creating-skills/references/scripts.md +166 -0
  80. package/skills/creating-skills/references/skills.md +165 -0
  81. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  82. package/skills/dashboard/SKILL.md +13 -11
  83. package/skills/dashboard/references/classification.md +22 -0
  84. package/skills/doctor/SKILL.md +10 -7
  85. package/skills/executing-plan/SKILL.md +4 -4
  86. package/skills/humanizer/SKILL.md +39 -0
  87. package/skills/humanizer/evals/evals.json +70 -0
  88. package/skills/humanizer/references/always-on-snippet.md +9 -0
  89. package/skills/humanizer/references/examples.md +48 -0
  90. package/skills/install/SKILL.md +75 -84
  91. package/skills/intent-curator/SKILL.md +5 -1
  92. package/skills/intent-discovery/SKILL.md +47 -0
  93. package/skills/intent-starting/SKILL.md +130 -0
  94. package/skills/intent-starting/evals/evals.json +117 -0
  95. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  96. package/skills/linking-intents/SKILL.md +48 -12
  97. package/skills/linking-intents/references/zettelkasten.md +7 -0
  98. package/skills/lock/SKILL.md +41 -0
  99. package/skills/managing-index/SKILL.md +6 -0
  100. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  101. package/skills/releasing/SKILL.md +44 -29
  102. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  103. package/skills/roadmap/SKILL.md +51 -0
  104. package/skills/roadmap/references/file-format.md +91 -0
  105. package/skills/roadmap/references/operations.md +78 -0
  106. package/skills/uninstall/SKILL.md +29 -11
  107. package/skills/update/SKILL.md +34 -23
  108. package/skills/versions/SKILL.md +27 -12
  109. package/skills/writing-plans/SKILL.md +10 -88
  110. package/skills/writing-plans/references/plan-format.md +102 -0
  111. package/templates/config.yml +8 -0
  112. package/templates/outcome.md +3 -0
  113. package/templates/revisions.md +58 -0
  114. package/templates/roadmap.md +30 -0
  115. package/skills/writing-instructions/SKILL.md +0 -159
  116. 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
@@ -159,6 +161,37 @@ class InstallerCore
159
161
  nums.select { |n| n >= 1 && n <= agents.size }.map { |n| agents[n - 1][:key] }
160
162
  end
161
163
 
164
+ # Resolve whether install should keep the user's existing statusline or switch it
165
+ # to Plastic's. Pure function of (settings file, argv, input, reinstall): no writes,
166
+ # so it stays fully unit-testable apart from merge_claude_hooks.
167
+ def statusline_choice(settings_path, argv: [], input: $stdin, reinstall: false)
168
+ existing_command = read_json_safe(settings_path)&.dig("statusLine", "command").to_s
169
+ return :plastic if existing_command.empty?
170
+ return :plastic if existing_command.include?("plastic-")
171
+
172
+ idx = argv.index("--statusline")
173
+ flag = idx && argv[idx + 1]
174
+ return flag.to_sym if %w[keep plastic].include?(flag)
175
+
176
+ return :keep if reinstall
177
+ return prompt_statusline(input: input) if input.tty?
178
+
179
+ :keep
180
+ end
181
+
182
+ def prompt_statusline(input: $stdin)
183
+ return :keep unless input.tty?
184
+
185
+ puts "An existing statusline was found in your settings.\n\n"
186
+ puts " 1. Keep my statusline (Plastic will not change it)"
187
+ puts " 2. Switch to Plastic's statusline"
188
+ puts
189
+ print "Select (1 or 2, Enter to keep): "
190
+ answer = input.gets&.strip
191
+
192
+ answer == "2" ? :plastic : :keep
193
+ end
194
+
162
195
  # --- Distribution phase ---
163
196
 
164
197
  def distribute(mode)
@@ -195,6 +228,7 @@ class InstallerCore
195
228
  def core_files
196
229
  {
197
230
  "PLASTIC.md" => "PLASTIC.md",
231
+ "PLASTIC-reference.md" => "PLASTIC-reference.md",
198
232
  "deprecations.yml" => "deprecations.yml",
199
233
  "scripts/folgezettel-id" => "scripts/folgezettel-id",
200
234
  "scripts/read-config" => "scripts/read-config",
@@ -203,17 +237,37 @@ class InstallerCore
203
237
  "scripts/hook-continue" => "scripts/hook-continue",
204
238
  "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
205
239
  "scripts/hook-gate-check" => "scripts/hook-gate-check",
240
+ "scripts/hook-savepoint-pre" => "scripts/hook-savepoint-pre",
206
241
  "scripts/hook-qmd-search" => "scripts/hook-qmd-search",
207
242
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
208
243
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
244
+ "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
209
245
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
246
+ "scripts/hook-lock-gate" => "scripts/hook-lock-gate",
210
247
  "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
248
+ "scripts/hook-retrieval-gate" => "scripts/hook-retrieval-gate",
249
+ "scripts/lib/retrieval_gate.rb" => "scripts/lib/retrieval_gate.rb",
211
250
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
212
251
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
252
+ "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
253
+ "scripts/plastic-lock" => "scripts/plastic-lock",
254
+ "scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
255
+ "scripts/agent-report" => "scripts/agent-report",
256
+ "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
257
+ "scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
213
258
  "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
259
+ "scripts/lib/dashboard_banner.rb" => "scripts/lib/dashboard_banner.rb",
214
260
  "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
215
261
  "scripts/qmd-sync" => "scripts/qmd-sync",
216
262
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
263
+ "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
264
+ "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
265
+ "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
266
+ "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
267
+ "scripts/lib/link_suggestions.rb" => "scripts/lib/link_suggestions.rb",
268
+ "scripts/project-links" => "scripts/project-links",
269
+ "scripts/link-suggest" => "scripts/link-suggest",
270
+ "scripts/rebuild-graph" => "scripts/rebuild-graph",
217
271
  "scripts/validate-intent" => "scripts/validate-intent",
218
272
  "scripts/new-intent" => "scripts/new-intent",
219
273
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
@@ -222,10 +276,12 @@ class InstallerCore
222
276
  "templates/plan.md" => "templates/plan.md",
223
277
  "templates/checklist.md" => "templates/checklist.md",
224
278
  "templates/outcome.md" => "templates/outcome.md",
279
+ "templates/revisions.md" => "templates/revisions.md",
225
280
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
226
281
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
227
282
  "scripts/provision-project-store" => "scripts/provision-project-store",
228
283
  "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
284
+ "scripts/lib/preflight.rb" => "scripts/lib/preflight.rb",
229
285
  "scripts/install.rb" => "scripts/install.rb",
230
286
  "scripts/update.rb" => "scripts/update.rb",
231
287
  "scripts/uninstall.rb" => "scripts/uninstall.rb",
@@ -298,7 +354,7 @@ class InstallerCore
298
354
  (data["files"] || {}).keys
299
355
  end
300
356
 
301
- def install_for_agent(key, force)
357
+ def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
302
358
  config = agent_config(key)
303
359
  return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
304
360
 
@@ -311,7 +367,7 @@ class InstallerCore
311
367
  old_files = manifest_files(manifest_path_for(key, config))
312
368
 
313
369
  result = case key
314
- when "claude" then install_claude(config, force)
370
+ when "claude" then install_claude(config, force, argv: argv, input: input, reinstall: reinstall)
315
371
  when "codex" then install_codex(config, force)
316
372
  when "hermes" then install_hermes(config, force)
317
373
  end
@@ -340,7 +396,7 @@ class InstallerCore
340
396
  removed
341
397
  end
342
398
 
343
- def install_claude(config, force)
399
+ def install_claude(config, force, argv: [], input: $stdin, reinstall: false)
344
400
  hooks_dir = File.join(config[:dir], "hooks")
345
401
  skills_root = File.join(config[:dir], "skills")
346
402
  plastic_dir = File.join(config[:dir], "plastic")
@@ -371,7 +427,7 @@ class InstallerCore
371
427
  installed += install_skills_flat(skills_source, skills_root) if File.directory?(skills_source)
372
428
 
373
429
  # Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
374
- installed += install_agents(File.join(config[:dir], "agents"))
430
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
375
431
 
376
432
  # Write VERSION
377
433
  version_file = File.join(plastic_dir, "VERSION")
@@ -383,7 +439,8 @@ class InstallerCore
383
439
 
384
440
  # Merge hooks + statusline into settings.json (no plugin registration)
385
441
  settings_path = File.join(config[:dir], "settings.json")
386
- merge_claude_hooks(settings_path)
442
+ choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
443
+ merge_claude_hooks(settings_path, choice: choice)
387
444
 
388
445
  # Write manifest
389
446
  manifest_path = File.join(plastic_dir, "manifest.json")
@@ -396,7 +453,7 @@ class InstallerCore
396
453
  installed = []
397
454
  skills_source = File.join(package_root, "skills")
398
455
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
399
- installed += install_agents(File.join(config[:dir], "agents"))
456
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
400
457
 
401
458
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
402
459
  { agent: config[:name], success: true, files: installed.size }
@@ -406,7 +463,7 @@ class InstallerCore
406
463
  installed = []
407
464
  skills_source = File.join(package_root, "skills")
408
465
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills")) if File.directory?(skills_source)
409
- installed += install_agents(File.join(config[:dir], "agents"))
466
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides)
410
467
 
411
468
  write_manifest(installed, File.join(config[:dir], "plastic-manifest.json"))
412
469
  { agent: config[:name], success: true, files: installed.size }
@@ -439,18 +496,51 @@ class InstallerCore
439
496
  # equivalents). Returns the installed destination paths so callers can append
440
497
  # them to `installed` before write_manifest (manifest + prune are then automatic).
441
498
  # No-op safe: returns [] when the package has no agents dir or it is empty.
442
- def install_agents(agents_root)
499
+ def install_agents(agents_root, models: {})
443
500
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
444
501
  return [] if sources.empty?
445
502
 
446
503
  FileUtils.mkdir_p(agents_root)
447
504
  sources.map do |src|
448
505
  dest = File.join(agents_root, File.basename(src))
449
- FileUtils.cp(src, dest)
506
+ basename = File.basename(src, ".md")
507
+ override = models[basename]
508
+ if override
509
+ File.write(dest, rewrite_model_line(File.read(src), override))
510
+ else
511
+ FileUtils.cp(src, dest)
512
+ end
450
513
  dest
451
514
  end
452
515
  end
453
516
 
517
+ # Rewrite the single top-level `model:` line in a YAML frontmatter block.
518
+ # Only the frontmatter (between the first two `---` fences) is touched.
519
+ def rewrite_model_line(content, model)
520
+ content.sub(/^model:[^\n]*$/, "model: #{model}")
521
+ end
522
+
523
+ # Resolve per-agent model overrides for this install: project config (when a
524
+ # project dir is known) overlaid on global config. Defaults are NOT included,
525
+ # so unconfigured agents keep their shipped frontmatter.
526
+ def agent_model_overrides(project_dir = nil)
527
+ global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
528
+ project_config =
529
+ if project_dir
530
+ load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml"))
531
+ else
532
+ {}
533
+ end
534
+ AgentModels.override_map(project_config: project_config, global_config: global_config)
535
+ end
536
+
537
+ def load_config_yaml(path)
538
+ return {} unless File.exist?(path)
539
+ YAML.safe_load(File.read(path)) || {}
540
+ rescue StandardError
541
+ {}
542
+ end
543
+
454
544
  # --- Legacy plugin migration ---
455
545
 
456
546
  # Earlier versions registered Plastic as a local marketplace plugin
@@ -516,7 +606,7 @@ class InstallerCore
516
606
 
517
607
  # --- settings.json merge (read-modify-write, never clobber) ---
518
608
 
519
- def merge_claude_hooks(settings_path)
609
+ def merge_claude_hooks(settings_path, choice: :plastic)
520
610
  settings = read_json_safe(settings_path) || {}
521
611
  return if settings.nil?
522
612
 
@@ -525,55 +615,9 @@ class InstallerCore
525
615
 
526
616
  purge_stale_plastic_hooks(hooks)
527
617
 
528
- plastic_hooks = {
529
- "SessionStart" => {
530
- "matcher" => "",
531
- "hooks" => [
532
- { "type" => "command", "command" => "#{hook_dir}/plastic-session-start", "statusMessage" => "Loading Plastic context..." },
533
- { "type" => "command", "command" => "#{hook_dir}/plastic-check-update", "statusMessage" => "" },
534
- ],
535
- },
536
- "PreCompact" => {
537
- "matcher" => "",
538
- "hooks" => [
539
- { "type" => "command", "command" => "#{hook_dir}/plastic-savepoint", "statusMessage" => "Saving Plastic intent state..." },
540
- ],
541
- },
542
- # PreToolUse carries TWO plastic groups with distinct matchers: the
543
- # code-gate (Write|Edit|NotebookEdit) and the create-gate (Write only, intent
544
- # 60b). A single group cannot carry two matchers, so this event maps to a
545
- # LIST of groups; the merge loop appends each (idempotent because the purge
546
- # pass removes all prior plastic groups first).
547
- "PreToolUse" => [
548
- {
549
- "matcher" => "Write|Edit|NotebookEdit",
550
- "hooks" => [
551
- { "type" => "command", "command" => "#{hook_dir}/plastic-code-gate", "statusMessage" => "Checking lifecycle gate..." },
552
- ],
553
- },
554
- {
555
- "matcher" => "Write",
556
- "hooks" => [
557
- { "type" => "command", "command" => "#{hook_dir}/plastic-create-gate", "statusMessage" => "Checking create gate..." },
558
- ],
559
- },
560
- ],
561
- "PostToolUse" => {
562
- "matcher" => "Write|Edit",
563
- "hooks" => [
564
- { "type" => "command", "command" => "#{hook_dir}/plastic-gate-check", "statusMessage" => "Checking lifecycle gates..." },
565
- ],
566
- },
567
- "UserPromptSubmit" => {
568
- "matcher" => "",
569
- "hooks" => [
570
- { "type" => "command", "command" => "#{hook_dir}/plastic-continue", "statusMessage" => "Checking for continue..." },
571
- { "type" => "command", "command" => "#{hook_dir}/plastic-future-intent-check", "statusMessage" => "Checking future intents..." },
572
- { "type" => "command", "command" => "#{hook_dir}/plastic-auto-arm", "statusMessage" => "Checking auto mode..." },
573
- { "type" => "command", "command" => "#{hook_dir}/plastic-qmd-search", "statusMessage" => "Searching QMD..." },
574
- ],
575
- },
576
- }
618
+ # Single source of truth (intent 108, D7): registrations live in
619
+ # HookRegistry; this merge only translates them into settings.json.
620
+ plastic_hooks = HookRegistry.claude_settings_hooks(hook_dir: hook_dir)
577
621
 
578
622
  plastic_hooks.each do |event, group|
579
623
  hooks[event] ||= []
@@ -604,7 +648,7 @@ class InstallerCore
604
648
  File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
605
649
  end
606
650
 
607
- settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" }
651
+ settings["statusLine"] = { "type" => "command", "command" => "#{hook_dir}/plastic-statusline" } if choice == :plastic
608
652
 
609
653
  # No plugin/marketplace registration: skills are flat personal skills
610
654
  # (plastic-<name>/) discovered directly from ~/.claude/skills.
@@ -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