@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.
- package/PLASTIC-reference.md +8 -6
- package/PLASTIC.md +52 -14
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +393 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +105 -27
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +34 -3
- package/scripts/lib/installer_core.rb +70 -13
- 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/lock.rb +186 -11
- 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/plastic-lock +76 -9
- 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 +45 -16
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- 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/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- 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/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +12 -11
- package/skills/update/SKILL.md +30 -17
- 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).
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|