@zalom/plastic 1.3.0 → 1.4.1

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 (71) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +52 -14
  3. package/hooks/hooks.json +5 -0
  4. package/hooks/links-gate +3 -0
  5. package/package.json +1 -1
  6. package/scripts/codex-hook +122 -8
  7. package/scripts/dashboard.rb +323 -71
  8. package/scripts/doctor.rb +393 -58
  9. package/scripts/end-intent +347 -43
  10. package/scripts/hook-links-gate +74 -0
  11. package/scripts/hook-lock-gate +8 -3
  12. package/scripts/install.rb +51 -6
  13. package/scripts/lib/bridge.rb +105 -27
  14. package/scripts/lib/config_asks.rb +110 -0
  15. package/scripts/lib/graph_rebuild.rb +30 -6
  16. package/scripts/lib/hook_registry.rb +34 -3
  17. package/scripts/lib/installer_core.rb +70 -13
  18. package/scripts/lib/intent_validator.rb +38 -10
  19. package/scripts/lib/links_gate.rb +140 -0
  20. package/scripts/lib/links_projection.rb +71 -12
  21. package/scripts/lib/lock.rb +186 -11
  22. package/scripts/lib/power_tools.rb +57 -14
  23. package/scripts/lib/project_validator.rb +113 -0
  24. package/scripts/lib/qmd_hook.rb +12 -8
  25. package/scripts/lib/restore_intent_v1.rb +154 -0
  26. package/scripts/lib/roadmap_queue.rb +1 -1
  27. package/scripts/lib/roadmap_savepoint.rb +38 -10
  28. package/scripts/lib/store_discovery.rb +77 -0
  29. package/scripts/lib/store_provisioning.rb +21 -12
  30. package/scripts/new-intent +10 -12
  31. package/scripts/plastic-lock +76 -9
  32. package/scripts/project-links +132 -35
  33. package/scripts/provision-project-store +18 -5
  34. package/scripts/read-config +1 -0
  35. package/scripts/rebuild-graph +42 -17
  36. package/scripts/restore-intent-v1 +288 -0
  37. package/scripts/roadmap-next +9 -2
  38. package/scripts/roadmap-savepoint +9 -1
  39. package/scripts/update.rb +50 -1
  40. package/scripts/validate-intent +3 -1
  41. package/scripts/validate-project +53 -0
  42. package/scripts/write-config +105 -0
  43. package/skills/auto/SKILL.md +45 -16
  44. package/skills/auto/references/agent-architecture.md +7 -0
  45. package/skills/auto/references/end-tail.md +27 -13
  46. package/skills/dashboard/SKILL.md +48 -25
  47. package/skills/dashboard/evals/evals.json +4 -4
  48. package/skills/dashboard/templates/dashboard-global.md +3 -5
  49. package/skills/dashboard/templates/dashboard-project.md +6 -18
  50. package/skills/install/SKILL.md +4 -4
  51. package/skills/intent-creating/SKILL.md +5 -0
  52. package/skills/intent-ending/SKILL.md +49 -36
  53. package/skills/intent-locking/SKILL.md +20 -2
  54. package/skills/intent-starting/SKILL.md +6 -4
  55. package/skills/project-continuing/SKILL.md +10 -0
  56. package/skills/project-continuing/evals/evals.json +3 -3
  57. package/skills/project-continuing/references/board-fill.md +13 -11
  58. package/skills/project-creating/SKILL.md +29 -1
  59. package/skills/releasing/SKILL.md +37 -19
  60. package/skills/roadmap/SKILL.md +9 -7
  61. package/skills/roadmap/references/file-format.md +14 -10
  62. package/skills/roadmap/references/operations.md +22 -18
  63. package/skills/roadmap-continuing/SKILL.md +5 -5
  64. package/skills/roadmap-continuing/evals/evals.json +3 -3
  65. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  66. package/skills/tutorial/SKILL.md +4 -4
  67. package/skills/tutorial/references/track-1-guided.md +2 -1
  68. package/skills/tutorial/references/track-2-auto.md +2 -1
  69. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +12 -11
  70. package/skills/update/SKILL.md +30 -17
  71. package/templates/roadmap.md +8 -8
@@ -17,9 +17,10 @@ class InstallerCore
17
17
  DEFAULT_PLASTIC_HOME = File.join(Dir.home, ".plastic")
18
18
 
19
19
  DEFAULT_AGENTS = [
20
- { key: "claude", name: "Claude Code", dir: File.join(Dir.home, ".claude"), flag: "--claude" },
20
+ { key: "claude", name: "Claude Code", dir: File.join(Dir.home, ".claude"), flag: "--claude",
21
+ skill_prefix: "/" },
21
22
  { key: "codex", name: "Codex CLI", dir: File.join(Dir.home, ".agents"),
22
- home_dir: File.join(Dir.home, ".codex"), flag: "--codex" },
23
+ home_dir: File.join(Dir.home, ".codex"), flag: "--codex", skill_prefix: "$" },
23
24
  { key: "hermes", name: "Hermes", dir: File.join(Dir.home, ".hermes"), flag: "--hermes" },
24
25
  ].freeze
25
26
 
@@ -43,7 +44,8 @@ class InstallerCore
43
44
  - The full conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly.
44
45
  It is generated and overwritten on Plastic updates, so never edit it.
45
46
  - Operational procedures are installed as skills under ~/.agents/skills/ (each
46
- plastic-<name>/SKILL.md). Use them for the lifecycle work they describe.
47
+ plastic-<name>/SKILL.md). Invoke one explicitly as $plastic-<name> (for example
48
+ $plastic-doctor), or let Codex pick one implicitly by matching its description.
47
49
  - Intents, specs, plans, checklists, and outcomes live under ~/.plastic/, never in
48
50
  the project tree.
49
51
 
@@ -175,7 +177,7 @@ class InstallerCore
175
177
  return ["claude"] unless input.tty?
176
178
 
177
179
  puts "Which agents should Plastic register for?\n\n"
178
- agents.each_with_index { |a, i| puts " #{i + 1}. #{a[:name]} (#{a[:dir]})" }
180
+ agents.each_with_index { |a, i| puts " #{i + 1}. #{a[:name]} (#{a[:home_dir] || a[:dir]})" }
179
181
  puts " #{agents.size + 1}. All"
180
182
  puts
181
183
 
@@ -251,16 +253,37 @@ class InstallerCore
251
253
  puts " \u{2705} Core files synced (v#{version})"
252
254
  end
253
255
 
256
+ # Templates ship in full: every file under templates/ in the repo must reach
257
+ # ~/.plastic/templates/ on install/update. Derived from Dir.glob so a new
258
+ # template file added later is registered automatically, closing the
259
+ # whack-a-mole pattern that hid templates/index.md and templates/project.yml
260
+ # from every install for five weeks (intent 190).
261
+ def template_files
262
+ Dir.glob(File.join(package_root, "templates", "*")).each_with_object({}) do |path, acc|
263
+ next unless File.file?(path)
264
+
265
+ rel = File.join("templates", File.basename(path))
266
+ acc[rel] = rel
267
+ end
268
+ end
269
+
254
270
  # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
255
271
  # must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
256
- # by install_sync_test).
272
+ # by install_sync_test). The templates half is glob-derived (template_files above); the
273
+ # rest stays a hand-written literal.
257
274
  def core_files
275
+ hand_registered_files.merge(template_files)
276
+ end
277
+
278
+ def hand_registered_files
258
279
  {
259
280
  "PLASTIC.md" => "PLASTIC.md",
260
281
  "PLASTIC-reference.md" => "PLASTIC-reference.md",
261
282
  "deprecations.yml" => "deprecations.yml",
283
+ "config_asks.yml" => "config_asks.yml",
262
284
  "scripts/folgezettel-id" => "scripts/folgezettel-id",
263
285
  "scripts/read-config" => "scripts/read-config",
286
+ "scripts/write-config" => "scripts/write-config",
264
287
  "scripts/select-update-target" => "scripts/select-update-target",
265
288
  "scripts/hook-session-start" => "scripts/hook-session-start",
266
289
  "scripts/hook-continue" => "scripts/hook-continue",
@@ -271,6 +294,7 @@ class InstallerCore
271
294
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
272
295
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
273
296
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
297
+ "scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
274
298
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
275
299
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
276
300
  "scripts/hook-lock-gate" => "scripts/hook-lock-gate",
@@ -296,6 +320,7 @@ class InstallerCore
296
320
  "scripts/roadmap-next" => "scripts/roadmap-next",
297
321
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
298
322
  "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
323
+ "scripts/lib/store_discovery.rb" => "scripts/lib/store_discovery.rb",
299
324
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
300
325
  "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
301
326
  "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
@@ -303,21 +328,21 @@ class InstallerCore
303
328
  "scripts/project-links" => "scripts/project-links",
304
329
  "scripts/link-suggest" => "scripts/link-suggest",
305
330
  "scripts/rebuild-graph" => "scripts/rebuild-graph",
331
+ "scripts/lib/restore_intent_v1.rb" => "scripts/lib/restore_intent_v1.rb",
332
+ "scripts/restore-intent-v1" => "scripts/restore-intent-v1",
306
333
  "scripts/validate-intent" => "scripts/validate-intent",
307
334
  "scripts/new-intent" => "scripts/new-intent",
308
335
  "scripts/end-intent" => "scripts/end-intent",
309
336
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
337
+ "scripts/hook-links-gate" => "scripts/hook-links-gate",
338
+ "scripts/lib/links_gate.rb" => "scripts/lib/links_gate.rb",
310
339
  "scripts/lib/apply_patch_envelope.rb" => "scripts/lib/apply_patch_envelope.rb",
311
340
  "scripts/codex-hook" => "scripts/codex-hook",
312
- "templates/intent.md" => "templates/intent.md",
313
- "templates/spec.md" => "templates/spec.md",
314
- "templates/plan.md" => "templates/plan.md",
315
- "templates/checklist.md" => "templates/checklist.md",
316
- "templates/outcome.md" => "templates/outcome.md",
317
- "templates/revisions.md" => "templates/revisions.md",
318
341
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
319
342
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
320
343
  "scripts/provision-project-store" => "scripts/provision-project-store",
344
+ "scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
345
+ "scripts/validate-project" => "scripts/validate-project",
321
346
  "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
322
347
  "scripts/lib/preflight.rb" => "scripts/lib/preflight.rb",
323
348
  "scripts/install.rb" => "scripts/install.rb",
@@ -397,12 +422,44 @@ class InstallerCore
397
422
  (data["files"] || {}).keys
398
423
  end
399
424
 
425
+ # Per-agent registration probe (intent 198, D7 follow-up). `installed?` in
426
+ # install.rb only answers "is Plastic core installed at all", which cannot
427
+ # tell two harnesses apart: once core is present, install.rb's old gate
428
+ # refused to add ANY new harness, even one that had never been touched. This
429
+ # asks the narrower, correct question, "has Plastic already registered
430
+ # files for THIS agent", using the signal already tracked for prune-on-update:
431
+ # the per-agent manifest (manifest_path_for). A missing manifest file, or a
432
+ # manifest whose "files" list is empty (write_manifest still writes one when
433
+ # nothing was installed), both mean nothing is registered for this agent yet.
434
+ # An unknown key is never "installed" (fail toward proceeding, since a caller
435
+ # that already validated the key gets its own "Unknown agent" result from
436
+ # install_for_agent).
437
+ def agent_installed?(key)
438
+ config = agent_config(key)
439
+ return false unless config
440
+ !manifest_files(manifest_path_for(key, config)).empty?
441
+ end
442
+
400
443
  def install_for_agent(key, force, argv: [], input: $stdin, reinstall: false)
401
444
  config = agent_config(key)
402
445
  return { agent: config[:name], success: false, reason: "Unknown agent" } unless config
403
446
 
404
- unless File.directory?(config[:dir])
405
- return { agent: config[:name], success: false, reason: "#{config[:dir]} not found \u{2014} #{config[:name]} not installed?" }
447
+ # Presence probe (intent 198, Decision D1): an agent that declares its own
448
+ # home directory (Codex, home_dir: ~/.codex) is checked THERE, because
449
+ # config[:dir] (~/.agents) is the shared cross-tool skills root, not
450
+ # anything Codex itself creates. A fresh Codex install has no ~/.agents
451
+ # yet, so testing config[:dir] aborted a genuinely-present Codex. Claude
452
+ # and Hermes declare no home_dir, so presence_dir resolves to config[:dir]
453
+ # exactly as before and their behavior is unchanged. The failure message
454
+ # reuses the same resolved directory, so it always names the directory
455
+ # actually tested. install_codex still needs config[:dir] to exist by the
456
+ # time it writes skills; install_skills_flat and generate_codex_agents
457
+ # already FileUtils.mkdir_p their own nested paths under config[:dir] and
458
+ # config[:home_dir], so a fresh install creates it as a side effect (no
459
+ # separate top-level mkdir_p is required here).
460
+ presence_dir = config[:home_dir] || config[:dir]
461
+ unless File.directory?(presence_dir)
462
+ return { agent: config[:name], success: false, reason: "#{presence_dir} not found, #{config[:name]} not installed?" }
406
463
  end
407
464
 
408
465
  # Capture the prior manifest so we can prune files that no longer ship
@@ -37,9 +37,18 @@ module IntentValidator
37
37
  # (for example "14", "14a", "4a1"). Mirrors scripts/folgezettel-id.
38
38
  ID_PATTERN = /\A([a-z0-9-]+:)?\d+[a-z0-9]*\z/
39
39
 
40
- # True iff `value` is a String matching the Folgezettel id form.
41
- def valid_id?(value)
42
- value.to_s.match?(ID_PATTERN)
40
+ # True iff `value` is a String matching the Folgezettel id form. When `known_stores` is
41
+ # given (an Array of store slugs, e.g. from StoreDiscovery.known_slugs), a cross-store
42
+ # prefix must also name a store in that set; a bare id (no prefix) is unaffected. When
43
+ # `known_stores` is nil (the default), only the shape is checked, so every existing
44
+ # caller keeps working unchanged (intent 189 D3).
45
+ def valid_id?(value, known_stores: nil)
46
+ s = value.to_s
47
+ return false unless s.match?(ID_PATTERN)
48
+ return true if known_stores.nil?
49
+
50
+ prefix = s[/\A([a-z0-9-]+):/, 1]
51
+ prefix.nil? || known_stores.include?(prefix)
43
52
  end
44
53
 
45
54
  # Read a file's YAML frontmatter, returning the parsed Hash (or {} when the
@@ -93,8 +102,10 @@ module IntentValidator
93
102
  end
94
103
 
95
104
  # PURE: given a parsed frontmatter Hash (or nil), return
96
- # { ok: Boolean, missing: [field names], errors: [human strings] }.
97
- def validate_frontmatter(fm)
105
+ # { ok: Boolean, missing: [field names], errors: [human strings] }. `known_stores`
106
+ # (optional, an Array of store slugs) is forwarded to valid_id? for each array-field
107
+ # element; when nil, only id shape is checked (unchanged existing behavior).
108
+ def validate_frontmatter(fm, known_stores: nil)
98
109
  unless fm.is_a?(Hash)
99
110
  return { ok: false, missing: REQUIRED_FIELDS.dup, errors: ["no frontmatter found"] }
100
111
  end
@@ -112,20 +123,37 @@ module IntentValidator
112
123
  end
113
124
 
114
125
  value.each do |element|
115
- errors << "#{key} has invalid id: #{element.inspect}" unless valid_id?(element)
126
+ next if valid_id?(element, known_stores: known_stores)
127
+
128
+ errors << id_error(key, element, known_stores)
116
129
  end
117
130
  end
118
131
 
119
132
  { ok: missing.empty? && errors.empty?, missing: missing, errors: errors }
120
133
  end
121
134
 
135
+ # Build the rejection message for one bad id. Distinguishes a shape failure (never a
136
+ # valid Folgezettel form at all) from a known-store rejection (right shape, but the
137
+ # store prefix names a store that does not exist), so an agent can tell "typo'd id" apart
138
+ # from "made up a store" (intent 189 D3).
139
+ def id_error(key, element, known_stores)
140
+ s = element.to_s
141
+ prefix = known_stores && s.match?(ID_PATTERN) ? s[/\A([a-z0-9-]+):/, 1] : nil
142
+ if prefix
143
+ "#{key} has invalid id: #{element.inspect} (store #{prefix.inspect} is not a known " \
144
+ "store; known stores: #{known_stores.sort.join(", ")})"
145
+ else
146
+ "#{key} has invalid id: #{element.inspect}"
147
+ end
148
+ end
149
+
122
150
  # PURE: combine the frontmatter result with section-structure findings for a
123
151
  # content STRING. Returns the frontmatter result hash extended with
124
152
  # :section_missing, :section_unknown, and folded section errors; :ok is the AND
125
153
  # of frontmatter and sections. Lets the create gate validate proposed content
126
154
  # (no file on disk) with the same definition as the CLI and doctor.
127
- def validate_content(content)
128
- fm_result = validate_frontmatter(parse_frontmatter_text(content))
155
+ def validate_content(content, known_stores: nil)
156
+ fm_result = validate_frontmatter(parse_frontmatter_text(content), known_stores: known_stores)
129
157
  sections = validate_sections(body_of(content))
130
158
  merge_sections(fm_result, sections)
131
159
  end
@@ -148,10 +176,10 @@ module IntentValidator
148
176
  # Resolve an intent directory's primary md file and validate its frontmatter
149
177
  # AND its sanctioned section structure. `plastic_home` is accepted for
150
178
  # house-style parity (injectable) even though validation reads the dir directly.
151
- def validate(intent_dir, plastic_home: File.join(Dir.home, ".plastic"))
179
+ def validate(intent_dir, plastic_home: File.join(Dir.home, ".plastic"), known_stores: nil)
152
180
  md_path = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
153
181
  content = File.exist?(md_path) ? File.read(md_path) : nil
154
- validate_content(content)
182
+ validate_content(content, known_stores: known_stores)
155
183
  end
156
184
 
157
185
  # PURE: cross-intent graph-shape invariants (intent 68). These need visibility
@@ -0,0 +1,140 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "intent_validator"
5
+ require_relative "links_section"
6
+ require_relative "links_projection"
7
+ require_relative "store_discovery"
8
+ require_relative "graph_rebuild"
9
+
10
+ # LinksGate, the write-time belt for the PLASTIC.md `## Links` contract
11
+ # (intent 192). Pure decision logic plus a small store-scanning glue (mirrors
12
+ # the glue ProjectLinks and Doctor each already carry independently for
13
+ # themselves; the CALCULATION is shared via LinksSection/LinksProjection so
14
+ # gate, projector, and doctor can never disagree by construction, even though
15
+ # each IO shell still does its own discovery, exactly as project-links and
16
+ # doctor already do today).
17
+ #
18
+ # #decision is the single entry point the PreToolUse hook
19
+ # (scripts/hook-links-gate) calls: given a file path and its BEFORE/AFTER
20
+ # content (BEFORE = on-disk, AFTER = the proposed Write/Edit result), it
21
+ # returns nil (allow) or a deny message (String). It only ever judges the
22
+ # REAL, fence-aware `## Links` section (LinksSection.extract_section); an
23
+ # edit that leaves that section untouched is always allowed, cheaply, with no
24
+ # store scan at all.
25
+ module LinksGate
26
+ module_function
27
+
28
+ DENY_MESSAGE =
29
+ "PLASTIC LINKS GATE - a ## Links line must come from the frontmatter " \
30
+ "sources/chain graph, never be hand-typed. This edit changes the ## Links " \
31
+ "section to something other than its frontmatter projection. Add the edge " \
32
+ "to sources or chain in frontmatter instead, then run scripts/project-links " \
33
+ "to regenerate ## Links.".freeze
34
+
35
+ # True iff `path` is an intent file inside its own equally-named store
36
+ # directory (store/<id>--<slug>/<id>--<slug>.md). Mirrors the create-gate
37
+ # path matcher (intent 60b) so the two gates agree on what "an intent file" is.
38
+ def intent_file?(path)
39
+ return false if path.to_s.strip.empty?
40
+
41
+ abs = File.expand_path(path)
42
+ dir = File.dirname(abs)
43
+ dir.match?(%r{/store/[^/]+--[^/]+\z}) && File.basename(abs) == "#{File.basename(dir)}.md"
44
+ end
45
+
46
+ # Decide whether to deny a Write/Edit whose proposed result is
47
+ # `after_content` (the on-disk content before the edit is `before_content`,
48
+ # "" when the file does not yet exist). Returns nil (allow, including every
49
+ # "cannot judge" case) or DENY_MESSAGE.
50
+ def decision(file_path:, before_content:, after_content:, plastic_home:)
51
+ return nil unless intent_file?(file_path)
52
+
53
+ before_links = safe_extract(before_content)
54
+ after_links = safe_extract(after_content)
55
+ return nil if before_links.nil? || after_links.nil? # ambiguous ## Links; cannot judge
56
+ return nil if before_links == after_links # this edit does not touch ## Links at all
57
+
58
+ fm = IntentValidator.parse_frontmatter_text(after_content.to_s)
59
+ return nil unless fm.is_a?(Hash) # no parseable frontmatter; cannot judge
60
+
61
+ referer_store = store_key_for(file_path, plastic_home)
62
+ return nil unless referer_store # not under any discovered store; cannot judge
63
+
64
+ ctx = build_context(plastic_home)
65
+ resolve = ->(ref) do
66
+ LinksProjection.resolve_ref_projection(
67
+ ref, referer_store: referer_store, relocation_map: ctx[:relocation_map],
68
+ store_index: ctx[:store_index], node_index: ctx[:node_index]
69
+ )
70
+ end
71
+
72
+ expected =
73
+ begin
74
+ LinksProjection.section(sources: Array(fm["sources"]), chain: Array(fm["chain"]),
75
+ resolve: resolve)
76
+ rescue LinksProjection::UnresolvedRef
77
+ return nil # a pre-existing dead frontmatter ref is a doctor finding, not this gate's job
78
+ end
79
+
80
+ after_links == expected ? nil : DENY_MESSAGE
81
+ end
82
+
83
+ # Fence-aware real-section extract, tolerant of an ambiguous file (returns
84
+ # nil rather than raising, so #decision can fail open on it).
85
+ def safe_extract(content)
86
+ LinksSection.extract_section(IntentValidator.body_of(content.to_s))
87
+ rescue LinksSection::AmbiguousLinks
88
+ nil
89
+ end
90
+
91
+ # Which discovered store (by store_index/node_index key) `file_path` lives
92
+ # under, or nil when it is not inside any store this plastic_home discovers.
93
+ def store_key_for(file_path, plastic_home)
94
+ abs = File.expand_path(file_path)
95
+ store_dir = File.dirname(File.dirname(abs)) # .../<store>/<id>--<slug>/<file>.md
96
+ StoreDiscovery.discover(plastic_home)[:stores]
97
+ .find { |s| File.expand_path(s[:store]) == store_dir }
98
+ &.fetch(:key)
99
+ end
100
+
101
+ # Build the store_index/node_index/relocation_map resolver context spanning
102
+ # every discovered store, the same shape ProjectLinks#run and Doctor each
103
+ # build for themselves. Re-scanned per call (no caching): this only runs on
104
+ # the rare edit that actually changes ## Links, so the cost is paid where it
105
+ # matters, not on every Edit/Write.
106
+ def build_context(plastic_home)
107
+ discovery = StoreDiscovery.discover(plastic_home)
108
+ store_index = {}
109
+ node_index = {}
110
+ index_texts = {}
111
+
112
+ discovery[:stores].each do |s|
113
+ nodes = load_nodes(s[:store])
114
+ store_index[s[:key]] = nodes.keys
115
+ node_index[s[:key]] = nodes.transform_values { |v| { basename: v[:basename], label: v[:label] } }
116
+ index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
117
+ end
118
+
119
+ { store_index: store_index, node_index: node_index,
120
+ relocation_map: GraphRebuild.build_relocation_map(index_texts) }
121
+ end
122
+
123
+ # { id => { basename:, label: } } for one store directory.
124
+ def load_nodes(store_dir)
125
+ nodes = {}
126
+ Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
127
+ dir = File.join(store_dir, entry)
128
+ next unless File.directory?(dir)
129
+
130
+ md = File.join(dir, "#{entry}.md")
131
+ next unless File.exist?(md)
132
+
133
+ fm = IntentValidator.parse_frontmatter(md)
134
+ next unless fm.is_a?(Hash) && fm["id"]
135
+
136
+ nodes[fm["id"].to_s] = { basename: entry, label: fm["intent"].to_s.strip }
137
+ end
138
+ nodes
139
+ end
140
+ end
@@ -27,14 +27,37 @@ module LinksProjection
27
27
  HEADING = "## Links"
28
28
  EMPTY_COMMENT = "<!-- No sources or chain; this intent has no graph edges to project. -->"
29
29
 
30
+ # A single rendered entry line, e.g. `- [[10--demo|Some intent]]` or
31
+ # `- [[knowdb:1--demo|Some intent]]`. The inverse of the line #entry renders.
32
+ ENTRY_LINE_RE = /\A- \[\[([^|\]]+)\|(.*)\]\]\z/
33
+
30
34
  # 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.
35
+ # ref, plus, when the resolver supplies one, the GraphRebuild status behind the
36
+ # miss (`:dead` or `:unknown_store`), so a rescuer can tell "genuinely gone" apart
37
+ # from "store not discovered this run, left untouched" instead of one generic
38
+ # failure. `reason` is nil when the resolver has no status to report (a live
39
+ # store/id whose node_index lookup still misses).
32
40
  class UnresolvedRef < StandardError
33
- attr_reader :ref
41
+ attr_reader :ref, :reason
34
42
 
35
- def initialize(ref)
43
+ def initialize(ref, reason: nil, store: nil)
36
44
  @ref = ref
37
- super("unresolved sources/chain ref: #{ref.inspect}")
45
+ @reason = reason
46
+ super("#{message_for(reason, store)}: #{ref.inspect}")
47
+ end
48
+
49
+ private
50
+
51
+ def message_for(reason, store)
52
+ case reason
53
+ when :dead
54
+ "unresolved sources/chain ref (dead, resolves to no intent)"
55
+ when :unknown_store
56
+ "unresolved sources/chain ref (unknown store #{store.inspect}, not discovered " \
57
+ "this run; left untouched, verify store discovery)"
58
+ else
59
+ "unresolved sources/chain ref"
60
+ end
38
61
  end
39
62
  end
40
63
 
@@ -76,6 +99,33 @@ module LinksProjection
76
99
  "#{HEADING}\n#{EMPTY_COMMENT}\n"
77
100
  end
78
101
 
102
+ # PURE. Parse a rendered or extracted `## Links` section's entry lines back into
103
+ # [{target:, label:}], in the order they appear. Ignores the heading line and the
104
+ # empty-state comment (and any other line that is not a `- [[target|label]]`
105
+ # line). The structural inverse of #entry's line shape. Used by callers
106
+ # (project-links) that need to compare or merge an OLD section's entries
107
+ # against a freshly-computed canonical one, one level below #section's own
108
+ # resolve-and-render.
109
+ def parse_entries(text)
110
+ text.to_s.each_line.filter_map do |line|
111
+ m = line.chomp.match(ENTRY_LINE_RE)
112
+ m && { target: m[1], label: m[2] }
113
+ end
114
+ end
115
+
116
+ # PURE. Render a final `## Links` block from an ALREADY-RESOLVED, ALREADY
117
+ # ORDERED list of {target:, label:} entries (no resolve callable; the caller
118
+ # has already done resolution/merging, e.g. project-links merging canonical
119
+ # entries with preserved orphan entries). Falls back to the empty-state
120
+ # comment when `entries` is empty. Shares the heading/line format with
121
+ # #section so the two can never render a different shape for the same list.
122
+ def render_entries(entries)
123
+ list = Array(entries)
124
+ return empty_section if list.empty?
125
+
126
+ (["#{HEADING}\n"] + list.map { |e| "- [[#{e[:target]}|#{e[:label]}]]\n" }).join
127
+ end
128
+
79
129
  # Resolve `ref`, render its entry, and append it to `rendered` UNLESS its resolved
80
130
  # target was already emitted (dedup by resolved target, first-seen wins so sources
81
131
  # precede chain). Mutates `seen` and `rendered`. Raises UnresolvedRef on a miss.
@@ -88,13 +138,18 @@ module LinksProjection
88
138
  end
89
139
 
90
140
  # PURE. Resolve one ref to [target, label]. Raises UnresolvedRef when the
91
- # resolver returns nothing usable.
141
+ # resolver returns nothing usable, carrying whatever :reason/:store the
142
+ # resolver supplied (see #resolve_ref_projection) so the miss stays
143
+ # distinguishable at the IO shell.
92
144
  def resolve_entry(ref, resolve)
93
145
  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?
146
+ hash = resolved.is_a?(Hash) ? resolved : {}
147
+ target = hash[:target] || hash["target"]
148
+ if target.nil? || target.to_s.strip.empty?
149
+ raise UnresolvedRef.new(ref, reason: hash[:reason], store: hash[:store])
150
+ end
96
151
 
97
- label = (resolved[:label] || resolved["label"]).to_s.strip
152
+ label = (hash[:label] || hash["label"]).to_s.strip
98
153
  [target.to_s, label]
99
154
  end
100
155
 
@@ -117,8 +172,10 @@ module LinksProjection
117
172
  # node_index — { store_key => { id => { basename:, label: } } } (spans all stores)
118
173
  #
119
174
  # 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).
175
+ # `<slug>:<id>--<slug>` for a cross-store one). On a miss, returns { reason: :dead }
176
+ # or { reason: :unknown_store, store: } instead of a bare nil, so #resolve_entry can
177
+ # raise UnresolvedRef with a distinguishable message (#section / #entry still raise
178
+ # either way; only the message differs).
122
179
  #
123
180
  # Uses GraphRebuild.resolve_ref so a relocation always wins over a coincidentally
124
181
  # reused id (the `global:24` impostor hazard), exactly as the frontmatter rebuild
@@ -142,8 +199,10 @@ module LinksProjection
142
199
  return nil if node.nil?
143
200
 
144
201
  { target: "#{slug}:#{node[:basename]}", label: node[:label] }
145
- else # :dead
146
- nil
202
+ when :dead
203
+ { reason: :dead }
204
+ when :unknown_store
205
+ { reason: :unknown_store, store: res[:store] }
147
206
  end
148
207
  end
149
208