@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.
- package/PLASTIC-reference.md +8 -6
- package/PLASTIC.md +24 -3
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +164 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/lib/bridge.rb +29 -1
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +2 -1
- package/scripts/lib/installer_core.rb +30 -7
- package/scripts/lib/intent_validator.rb +38 -10
- package/scripts/lib/links_gate.rb +140 -0
- package/scripts/lib/links_projection.rb +71 -12
- package/scripts/lib/power_tools.rb +57 -14
- package/scripts/lib/project_validator.rb +113 -0
- package/scripts/lib/qmd_hook.rb +12 -8
- package/scripts/lib/restore_intent_v1.rb +154 -0
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +38 -10
- package/scripts/lib/store_discovery.rb +77 -0
- package/scripts/lib/store_provisioning.rb +21 -12
- package/scripts/new-intent +10 -12
- package/scripts/project-links +132 -35
- package/scripts/provision-project-store +18 -5
- package/scripts/read-config +1 -0
- package/scripts/rebuild-graph +42 -17
- package/scripts/restore-intent-v1 +288 -0
- package/scripts/roadmap-next +9 -2
- package/scripts/roadmap-savepoint +9 -1
- package/scripts/update.rb +50 -1
- package/scripts/validate-intent +3 -1
- package/scripts/validate-project +53 -0
- package/scripts/write-config +105 -0
- package/skills/auto/SKILL.md +16 -10
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/install/SKILL.md +4 -4
- package/skills/intent-creating/SKILL.md +5 -0
- package/skills/intent-ending/SKILL.md +49 -36
- package/skills/project-creating/SKILL.md +29 -1
- package/skills/releasing/SKILL.md +37 -19
- package/skills/roadmap/SKILL.md +9 -7
- package/skills/roadmap/references/file-format.md +14 -10
- package/skills/roadmap/references/operations.md +22 -18
- package/skills/roadmap-continuing/SKILL.md +5 -5
- package/skills/roadmap-continuing/evals/evals.json +3 -3
- package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
- package/skills/update/SKILL.md +30 -17
- 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
|
-
# -
|
|
201
|
-
#
|
|
202
|
-
# - target id present
|
|
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
|
-
#
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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 = (
|
|
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)
|
|
121
|
-
#
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|