@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.
- package/PLASTIC.md +293 -7
- package/README.md +10 -0
- package/agents/plastic-brainstorming.md +2 -2
- package/agents/plastic-enforcer.md +17 -2
- package/agents/plastic-executor.md +3 -3
- package/agents/plastic-future-intent-researcher.md +2 -1
- package/agents/plastic-intent-curator.md +15 -7
- package/agents/plastic-intent-discovery.md +37 -0
- package/agents/plastic-planner.md +2 -2
- package/agents/plastic-spec-specialist.md +2 -2
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +28 -3
- package/hooks/lock-gate +21 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +150 -41
- package/package.json +1 -1
- package/scripts/agent-report +21 -0
- package/scripts/doctor.rb +404 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-create-gate +45 -9
- package/scripts/hook-gate-check +24 -23
- package/scripts/hook-lock-gate +83 -0
- package/scripts/hook-retrieval-gate +148 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +625 -47
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +64 -54
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +75 -0
- package/scripts/lib/qmd_hook.rb +38 -25
- package/scripts/lib/qmd_sync.rb +36 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +384 -0
- package/scripts/link-suggest +213 -0
- package/scripts/new-intent +138 -29
- package/scripts/plastic-lock +164 -0
- package/scripts/project-links +287 -0
- package/scripts/qmd-sync +50 -3
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +11 -3
- package/skills/auto/SKILL.md +98 -16
- package/skills/auto/evals/evals.json +48 -0
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/brainstorming/SKILL.md +1 -0
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/continuing/SKILL.md +30 -8
- package/skills/continuing/evals/evals.json +9 -0
- package/skills/creating-intent/SKILL.md +16 -2
- package/skills/creating-intent/evals/evals.json +16 -0
- package/skills/creating-intent/references/lifecycle.md +9 -4
- package/skills/creating-skills/SKILL.md +65 -0
- package/skills/creating-skills/evals/evals.json +108 -0
- package/skills/creating-skills/references/agents.md +168 -0
- package/skills/creating-skills/references/evals.md +41 -0
- package/skills/creating-skills/references/hooks.md +248 -0
- package/skills/creating-skills/references/progressive-disclosure.md +176 -0
- package/skills/creating-skills/references/scripts.md +166 -0
- package/skills/creating-skills/references/skills.md +165 -0
- package/skills/creating-skills/scripts/scaffold.rb +313 -0
- package/skills/dashboard/SKILL.md +5 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/doctor/SKILL.md +4 -1
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/humanizer/SKILL.md +39 -0
- package/skills/humanizer/evals/evals.json +70 -0
- package/skills/humanizer/references/always-on-snippet.md +9 -0
- package/skills/humanizer/references/examples.md +48 -0
- package/skills/intent-curator/SKILL.md +7 -1
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/intent-discovery/SKILL.md +46 -0
- package/skills/intent-starting/SKILL.md +127 -0
- package/skills/intent-starting/evals/evals.json +117 -0
- package/skills/intent-starting/references/boarding-matrix.md +35 -0
- package/skills/linking-intents/SKILL.md +54 -12
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +12 -0
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +32 -0
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- 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
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|