@zalom/plastic 1.3.0 → 1.4.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 (52) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +24 -3
  3. package/hooks/hooks.json +5 -0
  4. package/hooks/links-gate +3 -0
  5. package/package.json +1 -1
  6. package/scripts/doctor.rb +164 -58
  7. package/scripts/end-intent +347 -43
  8. package/scripts/hook-links-gate +74 -0
  9. package/scripts/lib/bridge.rb +29 -1
  10. package/scripts/lib/config_asks.rb +110 -0
  11. package/scripts/lib/graph_rebuild.rb +30 -6
  12. package/scripts/lib/hook_registry.rb +2 -1
  13. package/scripts/lib/installer_core.rb +30 -7
  14. package/scripts/lib/intent_validator.rb +38 -10
  15. package/scripts/lib/links_gate.rb +140 -0
  16. package/scripts/lib/links_projection.rb +71 -12
  17. package/scripts/lib/power_tools.rb +57 -14
  18. package/scripts/lib/project_validator.rb +113 -0
  19. package/scripts/lib/qmd_hook.rb +12 -8
  20. package/scripts/lib/restore_intent_v1.rb +154 -0
  21. package/scripts/lib/roadmap_queue.rb +1 -1
  22. package/scripts/lib/roadmap_savepoint.rb +38 -10
  23. package/scripts/lib/store_discovery.rb +77 -0
  24. package/scripts/lib/store_provisioning.rb +21 -12
  25. package/scripts/new-intent +10 -12
  26. package/scripts/project-links +132 -35
  27. package/scripts/provision-project-store +18 -5
  28. package/scripts/read-config +1 -0
  29. package/scripts/rebuild-graph +42 -17
  30. package/scripts/restore-intent-v1 +288 -0
  31. package/scripts/roadmap-next +9 -2
  32. package/scripts/roadmap-savepoint +9 -1
  33. package/scripts/update.rb +50 -1
  34. package/scripts/validate-intent +3 -1
  35. package/scripts/validate-project +53 -0
  36. package/scripts/write-config +105 -0
  37. package/skills/auto/SKILL.md +16 -10
  38. package/skills/auto/references/end-tail.md +27 -13
  39. package/skills/install/SKILL.md +4 -4
  40. package/skills/intent-creating/SKILL.md +5 -0
  41. package/skills/intent-ending/SKILL.md +49 -36
  42. package/skills/project-creating/SKILL.md +29 -1
  43. package/skills/releasing/SKILL.md +37 -19
  44. package/skills/roadmap/SKILL.md +9 -7
  45. package/skills/roadmap/references/file-format.md +14 -10
  46. package/skills/roadmap/references/operations.md +22 -18
  47. package/skills/roadmap-continuing/SKILL.md +5 -5
  48. package/skills/roadmap-continuing/evals/evals.json +3 -3
  49. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  50. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
  51. package/skills/update/SKILL.md +30 -17
  52. package/templates/roadmap.md +8 -8
@@ -0,0 +1,110 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+
6
+ # Shared resolution for config_asks.yml: a shipped, declarative manifest that
7
+ # lets a release announce a brand new config question without editing
8
+ # update.rb, doctor.rb, or any skill. Sibling of deprecations.yml (announce
9
+ # only); this one also tracks whether the user has answered or dismissed each
10
+ # entry, via config_asks_dismissed (mirrors deprecations_dismissed).
11
+ #
12
+ # Footgun for whoever adds the next entry: pending reads config.yml directly
13
+ # and never merges in read-config's DEFAULTS, so keying a new entry on
14
+ # something that already has a non-nil value in read-config's DEFAULTS would
15
+ # make that entry look unset, and therefore pending, forever, even though the
16
+ # rest of Plastic already treats it as answered by that default. See the
17
+ # matching note in config_asks.yml's schema header.
18
+ module ConfigAsks
19
+ FILENAME = "config_asks.yml"
20
+
21
+ # nil if the manifest is absent (a legitimate no-op: no release has declared
22
+ # a config question yet) or valid. A short description of the problem if the
23
+ # file exists but could not be read or parsed, or does not declare a
24
+ # config_asks array. Callers use this to tell "nothing declared" apart from
25
+ # "declared but broken" -- the manifest being unreadable must never look
26
+ # like a clean pass.
27
+ def self.manifest_error(plastic_home)
28
+ path = File.join(plastic_home, FILENAME)
29
+ return nil unless File.exist?(path)
30
+
31
+ data = YAML.safe_load(File.read(path))
32
+ return "#{FILENAME} does not declare a config_asks list" unless data.is_a?(Hash) && data["config_asks"].is_a?(Array)
33
+
34
+ nil
35
+ rescue StandardError => e
36
+ "#{FILENAME} could not be read: #{e.message}"
37
+ end
38
+
39
+ # All declared entries, or [] if the manifest is missing or malformed. Use
40
+ # manifest_error alongside this when the difference between "no entries"
41
+ # and "could not read the manifest" matters (it always does for a health
42
+ # check or an announcement -- see manifest_error above).
43
+ def self.load_entries(plastic_home)
44
+ path = File.join(plastic_home, FILENAME)
45
+ return [] unless File.exist?(path)
46
+
47
+ data = YAML.safe_load(File.read(path)) || {}
48
+ entries = data["config_asks"]
49
+ entries.is_a?(Array) ? entries : []
50
+ rescue StandardError
51
+ []
52
+ end
53
+
54
+ # Entries whose key is unset in config.yml AND whose id is not dismissed AND
55
+ # whose agents (if any) include agent_key. Deliberately ignores "introduced"
56
+ # -- see the schema comment in config_asks.yml for why (retro-fire).
57
+ #
58
+ # agent_key: nil means "do not filter by agent" (every entry applies); pass
59
+ # the caller's actual agent ("claude", "codex", "hermes") to respect an
60
+ # entry's agents scoping.
61
+ def self.pending(plastic_home, agent_key = nil)
62
+ config = load_config(plastic_home)
63
+ dismissed = Array(config["config_asks_dismissed"])
64
+
65
+ load_entries(plastic_home).select do |entry|
66
+ next false if dismissed.include?(entry["id"])
67
+ next false unless applies_to_agent?(entry, agent_key)
68
+
69
+ value = dig(config, entry["key"].to_s)
70
+ value.nil? || value == ""
71
+ end
72
+ end
73
+
74
+ # The exact command that answers one option of one entry.
75
+ def self.write_config_command(plastic_home, key, value)
76
+ "ruby #{File.join(plastic_home, "scripts", "write-config")} #{key} #{value}"
77
+ end
78
+
79
+ # The exact command that dismisses one entry ("not now" / keep default).
80
+ def self.dismiss_command(plastic_home, id)
81
+ "ruby #{File.join(plastic_home, "scripts", "write-config")} config_asks_dismissed --push #{id}"
82
+ end
83
+
84
+ # An entry with no agents field (or an empty one) applies to every agent.
85
+ # Otherwise it applies only when agent_key is nil (no filtering requested)
86
+ # or is present in the entry's agents list.
87
+ def self.applies_to_agent?(entry, agent_key)
88
+ scoped = Array(entry["agents"])
89
+ return true if scoped.empty?
90
+ return true if agent_key.nil?
91
+
92
+ scoped.include?(agent_key)
93
+ end
94
+ private_class_method :applies_to_agent?
95
+
96
+ def self.load_config(plastic_home)
97
+ path = File.join(plastic_home, "config.yml")
98
+ return {} unless File.exist?(path)
99
+
100
+ YAML.safe_load(File.read(path)) || {}
101
+ rescue StandardError
102
+ {}
103
+ end
104
+ private_class_method :load_config
105
+
106
+ def self.dig(hash, dotted_key)
107
+ dotted_key.split(".").reduce(hash) { |acc, k| acc.is_a?(Hash) ? acc[k] : nil }
108
+ end
109
+ private_class_method :dig
110
+ end
@@ -197,11 +197,17 @@ module GraphRebuild
197
197
 
198
198
  # Classify a resolved (store, id) relative to the referer, using the live store
199
199
  # index to detect dead targets:
200
- # - target id present in the referer's OWN store -> :same_store (collapse to bare)
201
- # - target id present in a DIFFERENT store -> :cross_store (keep slug:id)
202
- # - target id present NOWHERE -> :dead (drop)
200
+ # - the store token resolves to no known store -> :unknown_store (NEVER
201
+ # dropped; this is what makes a future discovery miss non-destructive, intent 189 D2)
202
+ # - target id present in the referer's OWN known store -> :same_store (collapse)
203
+ # - target id present in a DIFFERENT known store -> :cross_store (keep slug:id)
204
+ # - target id present NOWHERE in a known store -> :dead (drop)
203
205
  def classify(store_tok, bare, referer_store, store_index, original_ref)
204
206
  canonical = canonical_store_key(store_tok, store_index)
207
+ unless known_store?(canonical, store_index)
208
+ return { status: :unknown_store, ref: original_ref.to_s, store: store_tok }
209
+ end
210
+
205
211
  if ids_in(store_index, canonical).include?(bare)
206
212
  if canonical == referer_store
207
213
  { status: :same_store, id: bare }
@@ -213,6 +219,15 @@ module GraphRebuild
213
219
  end
214
220
  end
215
221
 
222
+ # True iff `canonical` names a store this run actually knows about (it is "global", or a
223
+ # literal key in `store_index`). A ref whose store token canonicalizes to anything else
224
+ # has never been discovered by this run, and must be classified :unknown_store, never
225
+ # :dead: those are different facts (store unknown vs. id absent from a known store) and
226
+ # only the second one means the ref is genuinely gone.
227
+ def known_store?(canonical, store_index)
228
+ canonical == "global" || (store_index || {}).key?(canonical)
229
+ end
230
+
216
231
  def ids_in(store_index, store_key)
217
232
  Array((store_index || {})[store_key])
218
233
  end
@@ -225,8 +240,11 @@ module GraphRebuild
225
240
  # relocation_map — from build_relocation_map (spans all stores)
226
241
  # store_index — { store_key => bare ids present } (spans all stores)
227
242
  #
228
- # Returns { nodes: <new map>, changes: [ {intent:, kind:, before:, after:} ] }.
229
- # kinds: :dedupe, :i3, :repoint, :collapse, :drop, :i1_backlink.
243
+ # Returns { nodes: <new map>, changes: [ {intent:, kind:, before:, after:} ],
244
+ # preserved: [ {intent:, field:, ref:, store:} ] }.
245
+ # kinds (changes, real mutations only): :dedupe, :i3, :repoint, :collapse, :drop,
246
+ # :i1_backlink. `preserved` is DIFFERENT: an unknown-store ref left byte-for-byte
247
+ # unchanged, reported for visibility, never counted as a "change" (nothing mutated).
230
248
  #
231
249
  # Order is load-bearing (spec Phase 2):
232
250
  # 1. dedupe each array order-preserving
@@ -247,6 +265,7 @@ module GraphRebuild
247
265
  end
248
266
 
249
267
  changes = []
268
+ preserved = []
250
269
 
251
270
  out.each do |id, edges|
252
271
  # 1. dedupe order-preserving
@@ -289,6 +308,11 @@ module GraphRebuild
289
308
  changes << { intent: id, kind: :repoint, field: field, before: ref, after: res[:ref] }
290
309
  end
291
310
  rebuilt << res[:ref]
311
+ when :unknown_store
312
+ # NEVER drop: the store is unrecognized, not the id absent from a known store.
313
+ # Preserve byte-for-byte and report separately from `changes` (nothing mutated).
314
+ preserved << { intent: id, field: field, ref: ref, store: res[:store] }
315
+ rebuilt << ref
292
316
  when :dead
293
317
  changes << { intent: id, kind: :drop, field: field, before: ref, after: nil }
294
318
  # dropped: not appended
@@ -323,6 +347,6 @@ module GraphRebuild
323
347
  end
324
348
  end
325
349
 
326
- { nodes: out, changes: changes }
350
+ { nodes: out, changes: changes, preserved: preserved }
327
351
  end
328
352
  end
@@ -48,6 +48,7 @@ module HookRegistry
48
48
  ] },
49
49
  { "matcher" => "Write|Edit", "hooks" => [
50
50
  { "name" => "savepoint-pre", "status" => "Recording stage start..." },
51
+ { "name" => "links-gate", "status" => "Checking Links gate..." },
51
52
  ] },
52
53
  { "matcher" => CREATE_MATCHER, "hooks" => [
53
54
  { "name" => "create-gate", "status" => "Checking create gate..." },
@@ -84,7 +85,7 @@ module HookRegistry
84
85
  # [{"matcher","hooks":[{"type":"command","command","statusMessage"}]}]}},
85
86
  # identical to Claude's shape, string command. Single source of truth (108 D7):
86
87
  # any drift from `events` is a bug, pinned by test.
87
- CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre create-gate].freeze
88
+ CODEX_PRE_HOOKS = %w[code-gate lock-gate savepoint-pre links-gate create-gate].freeze
88
89
  CODEX_POST_HOOKS = %w[gate-check].freeze
89
90
 
90
91
  def codex_hooks_json(dispatcher_path:)
@@ -251,16 +251,37 @@ class InstallerCore
251
251
  puts " \u{2705} Core files synced (v#{version})"
252
252
  end
253
253
 
254
+ # Templates ship in full: every file under templates/ in the repo must reach
255
+ # ~/.plastic/templates/ on install/update. Derived from Dir.glob so a new
256
+ # template file added later is registered automatically, closing the
257
+ # whack-a-mole pattern that hid templates/index.md and templates/project.yml
258
+ # from every install for five weeks (intent 190).
259
+ def template_files
260
+ Dir.glob(File.join(package_root, "templates", "*")).each_with_object({}) do |path, acc|
261
+ next unless File.file?(path)
262
+
263
+ rel = File.join("templates", File.basename(path))
264
+ acc[rel] = rel
265
+ end
266
+ end
267
+
254
268
  # Files copied into ~/.plastic on install/update. Every verb script + the shared lib
255
269
  # must be here so the installed ~/.plastic/scripts copy is self-complete (sync-guarded
256
- # by install_sync_test).
270
+ # by install_sync_test). The templates half is glob-derived (template_files above); the
271
+ # rest stays a hand-written literal.
257
272
  def core_files
273
+ hand_registered_files.merge(template_files)
274
+ end
275
+
276
+ def hand_registered_files
258
277
  {
259
278
  "PLASTIC.md" => "PLASTIC.md",
260
279
  "PLASTIC-reference.md" => "PLASTIC-reference.md",
261
280
  "deprecations.yml" => "deprecations.yml",
281
+ "config_asks.yml" => "config_asks.yml",
262
282
  "scripts/folgezettel-id" => "scripts/folgezettel-id",
263
283
  "scripts/read-config" => "scripts/read-config",
284
+ "scripts/write-config" => "scripts/write-config",
264
285
  "scripts/select-update-target" => "scripts/select-update-target",
265
286
  "scripts/hook-session-start" => "scripts/hook-session-start",
266
287
  "scripts/hook-continue" => "scripts/hook-continue",
@@ -271,6 +292,7 @@ class InstallerCore
271
292
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
272
293
  "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
273
294
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
295
+ "scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
274
296
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
275
297
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
276
298
  "scripts/hook-lock-gate" => "scripts/hook-lock-gate",
@@ -296,6 +318,7 @@ class InstallerCore
296
318
  "scripts/roadmap-next" => "scripts/roadmap-next",
297
319
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
298
320
  "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
321
+ "scripts/lib/store_discovery.rb" => "scripts/lib/store_discovery.rb",
299
322
  "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
300
323
  "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
301
324
  "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
@@ -303,21 +326,21 @@ class InstallerCore
303
326
  "scripts/project-links" => "scripts/project-links",
304
327
  "scripts/link-suggest" => "scripts/link-suggest",
305
328
  "scripts/rebuild-graph" => "scripts/rebuild-graph",
329
+ "scripts/lib/restore_intent_v1.rb" => "scripts/lib/restore_intent_v1.rb",
330
+ "scripts/restore-intent-v1" => "scripts/restore-intent-v1",
306
331
  "scripts/validate-intent" => "scripts/validate-intent",
307
332
  "scripts/new-intent" => "scripts/new-intent",
308
333
  "scripts/end-intent" => "scripts/end-intent",
309
334
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
335
+ "scripts/hook-links-gate" => "scripts/hook-links-gate",
336
+ "scripts/lib/links_gate.rb" => "scripts/lib/links_gate.rb",
310
337
  "scripts/lib/apply_patch_envelope.rb" => "scripts/lib/apply_patch_envelope.rb",
311
338
  "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
339
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
319
340
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
320
341
  "scripts/provision-project-store" => "scripts/provision-project-store",
342
+ "scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
343
+ "scripts/validate-project" => "scripts/validate-project",
321
344
  "scripts/lib/installer_core.rb" => "scripts/lib/installer_core.rb",
322
345
  "scripts/lib/preflight.rb" => "scripts/lib/preflight.rb",
323
346
  "scripts/install.rb" => "scripts/install.rb",
@@ -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