@zalom/plastic 1.2.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 +68 -6
- package/README.md +5 -0
- package/agents/plastic-advisor.md +56 -0
- package/agents/plastic-enforcer.md +9 -1
- package/agents/plastic-faux-advisor.md +174 -0
- package/agents/plastic-future-intent-researcher.md +1 -0
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/hooks/statusline +1 -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/install.rb +8 -0
- package/scripts/lib/agent_models.rb +36 -9
- 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 +130 -23
- 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/agent-advisor/SKILL.md +92 -0
- package/skills/agent-advisor/references/advisor-protocol.md +245 -0
- package/skills/auto/SKILL.md +26 -12
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/install/SKILL.md +30 -2
- 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 +34 -4
- package/templates/config.yml +31 -6
- package/templates/roadmap.md +8 -8
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_rebuild"
|
|
5
|
+
require_relative "frontmatter_writer"
|
|
6
|
+
|
|
7
|
+
# RestoreIntentV1 - pure graph math for restoring a completed intent's frontmatter
|
|
8
|
+
# graph across a v1 prose revert (intent 193). No file IO, no git, no `system`.
|
|
9
|
+
#
|
|
10
|
+
# The rule this module carries: prose reverts to v1; the sources/chain graph is
|
|
11
|
+
# APPEND-ONLY and is the UNION of the v1 snapshot and the current snapshot, never
|
|
12
|
+
# a re-derivation from other intents' reciprocal edges (that would silently erase
|
|
13
|
+
# legitimate I2-asymmetry edges doctor.rb never auto-fixes). Before the union is
|
|
14
|
+
# written, every edge (from either snapshot) is target-resolved by reusing
|
|
15
|
+
# GraphRebuild.resolve_ref verbatim, the same classifier rebuild-graph and
|
|
16
|
+
# doctor.rb already share: a :dead edge (resolves to no id in any known store) is
|
|
17
|
+
# dropped and reported; :same_store, :cross_store, and :unknown_store edges are
|
|
18
|
+
# all kept (bias toward preserving an edge that might be real; only positive proof
|
|
19
|
+
# of non-existence justifies a drop). The value WRITTEN for a kept edge is the
|
|
20
|
+
# RESOLVED value GraphRebuild returns (classification[:id] for :same_store,
|
|
21
|
+
# classification[:ref] for :cross_store), never the raw pre-resolution ref, so
|
|
22
|
+
# this tool can never disagree with rebuild-graph/doctor about the canonical form
|
|
23
|
+
# of an edge it just wrote (D14).
|
|
24
|
+
module RestoreIntentV1
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# PURE. Computes the desired sources/chain for a restore.
|
|
28
|
+
#
|
|
29
|
+
# Returns:
|
|
30
|
+
# { sources: [...], chain: [...],
|
|
31
|
+
# dropped: [ { field: :sources|:chain, ref: "<id or store:id>" }, ... ],
|
|
32
|
+
# unverified: [ { field: :sources|:chain, ref: "<id or store:id>" }, ... ],
|
|
33
|
+
# current_only: [ { field: :sources|:chain, ref: "<id or store:id>" }, ... ] }
|
|
34
|
+
#
|
|
35
|
+
# `current_only` names every edge present in the CURRENT snapshot but absent from
|
|
36
|
+
# the v1 snapshot (D3 transparency): an edge added by the very change being
|
|
37
|
+
# reverted, which the union now carries forward. Named explicitly regardless of
|
|
38
|
+
# its target-resolution outcome, so it is never a silent side effect.
|
|
39
|
+
def compute_graph(v1_sources:, v1_chain:, current_sources:, current_chain:,
|
|
40
|
+
referer_store:, relocation_map:, store_index:)
|
|
41
|
+
sources_result = resolve_union(v1_sources, current_sources, :sources,
|
|
42
|
+
referer_store, relocation_map, store_index)
|
|
43
|
+
chain_result = resolve_union(v1_chain, current_chain, :chain,
|
|
44
|
+
referer_store, relocation_map, store_index)
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
sources: sources_result[:kept],
|
|
48
|
+
chain: chain_result[:kept],
|
|
49
|
+
dropped: sources_result[:dropped] + chain_result[:dropped],
|
|
50
|
+
unverified: sources_result[:unverified] + chain_result[:unverified],
|
|
51
|
+
current_only: current_only_edges(v1_sources, current_sources, :sources) +
|
|
52
|
+
current_only_edges(v1_chain, current_chain, :chain),
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# PURE. Union two edge arrays (deduped, order-preserving, first array's order
|
|
57
|
+
# wins for shared entries), then target-resolve each via GraphRebuild.resolve_ref.
|
|
58
|
+
# WRITES THE RESOLVED VALUE, not the raw union member: a redundant same-store
|
|
59
|
+
# prefix (e.g. "global:15" written by a "global" intent) collapses to the bare
|
|
60
|
+
# "15", and a relocated ref is repointed to its resolved "store:id" form, exactly
|
|
61
|
+
# matching what GraphRebuild.rebuild_store itself writes (res[:id] / res[:ref]).
|
|
62
|
+
# Resolution can make two distinct union members collapse to the same resolved
|
|
63
|
+
# value, so `kept` is de-duped again after resolution.
|
|
64
|
+
def resolve_union(v1_edges, current_edges, field, referer_store, relocation_map, store_index)
|
|
65
|
+
union = (Array(v1_edges).map(&:to_s) + Array(current_edges).map(&:to_s)).uniq
|
|
66
|
+
kept = []
|
|
67
|
+
dropped = []
|
|
68
|
+
unverified = []
|
|
69
|
+
|
|
70
|
+
union.each do |ref|
|
|
71
|
+
classification = GraphRebuild.resolve_ref(
|
|
72
|
+
ref, referer_store: referer_store, relocation_map: relocation_map, store_index: store_index
|
|
73
|
+
)
|
|
74
|
+
case classification[:status]
|
|
75
|
+
when :dead
|
|
76
|
+
dropped << { field: field, ref: ref }
|
|
77
|
+
when :unknown_store
|
|
78
|
+
kept << ref
|
|
79
|
+
unverified << { field: field, ref: ref }
|
|
80
|
+
when :same_store
|
|
81
|
+
kept << classification[:id]
|
|
82
|
+
when :cross_store
|
|
83
|
+
kept << classification[:ref]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
{ kept: kept.uniq, dropped: dropped, unverified: unverified }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# PURE. Every edge present in `current_edges` but absent from `v1_edges`
|
|
91
|
+
# (raw, before target resolution): the set the restore is about to carry
|
|
92
|
+
# forward that v1 itself never had.
|
|
93
|
+
def current_only_edges(v1_edges, current_edges, field)
|
|
94
|
+
v1_set = Array(v1_edges).map(&:to_s)
|
|
95
|
+
Array(current_edges).map(&:to_s).uniq.reject { |ref| v1_set.include?(ref) }
|
|
96
|
+
.map { |ref| { field: field, ref: ref } }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# PURE. Reapply the computed graph onto v1's exact prose. Delegates entirely to
|
|
100
|
+
# FrontmatterWriter; this module never rewrites YAML itself.
|
|
101
|
+
def apply_graph(v1_content, desired_sources:, desired_chain:)
|
|
102
|
+
FrontmatterWriter.rewrite_arrays(v1_content, sources: desired_sources, chain: desired_chain)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# PURE. Render one revisions.md entry (intent 107's append-only, move-and-record
|
|
106
|
+
# convention). `n` is the next revision number for this intent's revisions.md.
|
|
107
|
+
# `files` names every file reverted to its v1 content in this restore.
|
|
108
|
+
def render_revision_entry(n, at:, timestamp:, files:, before_sources:, after_sources:,
|
|
109
|
+
before_chain:, after_chain:, dropped:)
|
|
110
|
+
lines = []
|
|
111
|
+
lines << "## Revision v#{n} - #{timestamp}"
|
|
112
|
+
lines << "- Why: restore-to-v1 preserved the frontmatter graph across a completed-intent " \
|
|
113
|
+
"restore [rule: restored-to-v1]"
|
|
114
|
+
lines << "- Prior location: frontmatter - sources/chain; prose reverted to ref #{at}"
|
|
115
|
+
lines << "- Files reverted to v1: #{files.empty? ? "(none, already at v1)" : files.join(", ")}"
|
|
116
|
+
lines << "- Change: sources (before: #{before_sources.inspect} -> after: #{after_sources.inspect}); " \
|
|
117
|
+
"chain (before: #{before_chain.inspect} -> after: #{after_chain.inspect})"
|
|
118
|
+
unless dropped.empty?
|
|
119
|
+
lines << ""
|
|
120
|
+
dropped.each do |d|
|
|
121
|
+
lines << " Dropped dead edge in #{d[:field]} -> #{d[:ref]}: target intent does not exist."
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
"#{lines.join("\n")}\n"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# PURE. Render the dry-run/apply human-readable report. `v1` and `current` are
|
|
128
|
+
# { sources:, chain: } snapshots shown alongside the resulting union so a
|
|
129
|
+
# reviewer can see all three shapes without recomputing anything by hand (spec
|
|
130
|
+
# acceptance criterion: dry-run prints the v1 graph, the current graph, and the
|
|
131
|
+
# resulting union, not only the union).
|
|
132
|
+
def render_report(base:, at:, prose_changes:, v1:, current:, graph:, apply:)
|
|
133
|
+
lines = []
|
|
134
|
+
lines << "restore-intent-v1: #{base} at #{at} (#{apply ? "APPLY" : "DRY RUN"})"
|
|
135
|
+
prose_changes.each { |f| lines << " prose: revert #{f}" }
|
|
136
|
+
lines << " v1 sources -> #{v1[:sources].inspect}"
|
|
137
|
+
lines << " v1 chain -> #{v1[:chain].inspect}"
|
|
138
|
+
lines << " current sources -> #{current[:sources].inspect}"
|
|
139
|
+
lines << " current chain -> #{current[:chain].inspect}"
|
|
140
|
+
lines << " union sources -> #{graph[:sources].inspect}"
|
|
141
|
+
lines << " union chain -> #{graph[:chain].inspect}"
|
|
142
|
+
graph[:dropped].each do |d|
|
|
143
|
+
lines << " DROPPED dead edge (#{d[:field]}): #{d[:ref]} - target intent does not exist"
|
|
144
|
+
end
|
|
145
|
+
graph[:unverified].each do |d|
|
|
146
|
+
lines << " UNVERIFIED edge (#{d[:field]}): #{d[:ref]} - store unknown, kept"
|
|
147
|
+
end
|
|
148
|
+
graph[:current_only].each do |d|
|
|
149
|
+
lines << " CURRENT-ONLY edge (#{d[:field]}): #{d[:ref]} - added by the change being " \
|
|
150
|
+
"reverted, now surviving the restore"
|
|
151
|
+
end
|
|
152
|
+
lines.join("\n")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -112,7 +112,7 @@ class RoadmapQueue
|
|
|
112
112
|
|
|
113
113
|
def parse_roadmap(path)
|
|
114
114
|
text = File.read(path)
|
|
115
|
-
{ slug: File.basename(path, ".md"), path: path, waves: parse_waves(
|
|
115
|
+
{ slug: File.basename(path, ".md"), path: path, waves: parse_waves(RoadmapSavepoint.grouping_section_body(text, path: path)) }
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def parse_waves(waves_body)
|
|
@@ -21,12 +21,20 @@ require "fileutils"
|
|
|
21
21
|
module RoadmapSavepoint
|
|
22
22
|
module_function
|
|
23
23
|
|
|
24
|
-
EVENTS = %w[created dispatched parked merged release handoff closed added reordered wave].freeze
|
|
24
|
+
EVENTS = %w[created dispatched parked merged release handoff closed added reordered wave batch].freeze
|
|
25
|
+
|
|
26
|
+
# Raised by grouping_section_body when a roadmap has neither '## Batches' (canonical, owner
|
|
27
|
+
# ruling 145) nor '## Waves' (legacy) as its top-level grouping heading (intent 196): a
|
|
28
|
+
# malformed roadmap must fail loudly, never silently parse as zero entries.
|
|
29
|
+
class MissingGroupingHeading < StandardError; end
|
|
30
|
+
|
|
31
|
+
# Canonical first, legacy fallback second. A roadmap file has exactly one of these, never both.
|
|
32
|
+
GROUPING_HEADINGS = %w[Batches Waves].freeze
|
|
25
33
|
|
|
26
34
|
# Keyword -> event classification for `rebuild`, checked top to bottom, first match wins.
|
|
27
35
|
# Kept small and deterministic (action 1). Order matters: more specific/rarer words are
|
|
28
|
-
# checked before the broader "wave"
|
|
29
|
-
# classifiable line never shadows its real event.
|
|
36
|
+
# checked before the broader "wave"/"batch" fallbacks so an incidental "wave" or "batch"
|
|
37
|
+
# mention in an otherwise classifiable line never shadows its real event.
|
|
30
38
|
KEYWORD_TABLE = [
|
|
31
39
|
[/\bclosed\b/i, "closed"],
|
|
32
40
|
[/\bhanded off\b|\bhandoff\b/i, "handoff"],
|
|
@@ -38,6 +46,7 @@ module RoadmapSavepoint
|
|
|
38
46
|
[/\badded\b|\badds\b/i, "added"],
|
|
39
47
|
[/\bcreated\b/i, "created"],
|
|
40
48
|
[/\bwave\b/i, "wave"],
|
|
49
|
+
[/\bbatch(?:es)?\b/i, "batch"],
|
|
41
50
|
].freeze
|
|
42
51
|
|
|
43
52
|
# --- append -----------------------------------------------------------------
|
|
@@ -88,15 +97,16 @@ module RoadmapSavepoint
|
|
|
88
97
|
# --- rebuild ------------------------------------------------------------------
|
|
89
98
|
|
|
90
99
|
# Reconstruct the paired ledger deterministically from the roadmap file's `## Log` (never the
|
|
91
|
-
# roadmap `.md`, which is read-only here), cross-checked against
|
|
92
|
-
#
|
|
93
|
-
# on-disk source (the Log, or INDEX
|
|
94
|
-
# not emitted (D4, never invented).
|
|
95
|
-
#
|
|
100
|
+
# roadmap `.md`, which is read-only here), cross-checked against the roadmap's grouping
|
|
101
|
+
# section (`## Batches`, or legacy `## Waves`) and the tier's INDEX so every `delivered` wave
|
|
102
|
+
# entry has a `merged` line. Every timestamp comes from an on-disk source (the Log, or INDEX
|
|
103
|
+
# `## Completed`); an entry with no recoverable timestamp is not emitted (D4, never invented).
|
|
104
|
+
# Overwrites the ledger (the one operation allowed to rewrite it, matching
|
|
105
|
+
# `Bridge.rebuild_savepoint`). Returns the number of lines written.
|
|
96
106
|
def rebuild(roadmap_path)
|
|
97
107
|
text = File.read(roadmap_path)
|
|
98
108
|
log_lines = classify_log(section_body(text, "Log"))
|
|
99
|
-
delivered_ids = delivered_wave_ids(
|
|
109
|
+
delivered_ids = delivered_wave_ids(grouping_section_body(text, path: roadmap_path))
|
|
100
110
|
backfilled = backfill_merged_lines(log_lines, delivered_ids, roadmap_path)
|
|
101
111
|
|
|
102
112
|
lines = dedup_pairs(log_lines + backfilled)
|
|
@@ -139,7 +149,8 @@ module RoadmapSavepoint
|
|
|
139
149
|
|
|
140
150
|
WAVE_ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+.+—\s*(\S+)\s*\z/.freeze
|
|
141
151
|
|
|
142
|
-
# Intent ids of every `[x] ... — delivered` entry in the
|
|
152
|
+
# Intent ids of every `[x] ... — delivered` entry in the roadmap's grouping section body
|
|
153
|
+
# (`## Batches`, or legacy `## Waves`).
|
|
143
154
|
def delivered_wave_ids(waves_body)
|
|
144
155
|
waves_body.each_line.filter_map do |line|
|
|
145
156
|
m = line.strip.match(WAVE_ENTRY)
|
|
@@ -198,6 +209,23 @@ module RoadmapSavepoint
|
|
|
198
209
|
end
|
|
199
210
|
private_class_method :section_body
|
|
200
211
|
|
|
212
|
+
# The one shared fix point for the Batches/Waves grammar (intent 196). '## Batches' is
|
|
213
|
+
# canonical (owner ruling 145); '## Waves' is the legacy heading the three pre-ruling roadmaps
|
|
214
|
+
# still use and must keep parsing forever (145 also forbids renaming those files). Public,
|
|
215
|
+
# because roadmap_queue.rb calls it instead of holding its own copy of the heading string: that
|
|
216
|
+
# file already depends one-directionally on this module (require_relative "roadmap_savepoint",
|
|
217
|
+
# already calling `ledger_path_for`), so this is the smaller diff than a new shared module.
|
|
218
|
+
# Raises MissingGroupingHeading, naming the offending path, when neither heading is present.
|
|
219
|
+
def grouping_section_body(text, path: nil)
|
|
220
|
+
GROUPING_HEADINGS.each do |heading|
|
|
221
|
+
m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
|
|
222
|
+
return m[1] if m
|
|
223
|
+
end
|
|
224
|
+
raise MissingGroupingHeading,
|
|
225
|
+
"#{path || '(unknown roadmap file)'}: found neither '## Batches' (canonical) nor " \
|
|
226
|
+
"'## Waves' (legacy) grouping heading"
|
|
227
|
+
end
|
|
228
|
+
|
|
201
229
|
# Stable dedup on the `(event, detail)` pair, keeping the first occurrence in the given
|
|
202
230
|
# (already chronological-then-backfill-appended) order.
|
|
203
231
|
def dedup_pairs(lines)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "store_provisioning"
|
|
5
|
+
|
|
6
|
+
# StoreDiscovery: the single source of truth for "what stores exist" (intent 189).
|
|
7
|
+
#
|
|
8
|
+
# Two failure modes must both be avoided: missing a real store (a live cross-store ref
|
|
9
|
+
# into it gets classified dead and DELETED by rebuild-graph, the data-loss bug this module
|
|
10
|
+
# fixes) and silently treating a registered-but-unprovisioned project as an empty store (a
|
|
11
|
+
# different silent failure). So discovery is a SUPERSET: the global store (if it exists)
|
|
12
|
+
# plus every `projects/<slug>/store` directory that exists on disk, UNIONED with every slug
|
|
13
|
+
# registered in projects.yml. A registered slug with no store directory contributes no ids
|
|
14
|
+
# and is reported separately in `missing`, never silently dropped.
|
|
15
|
+
#
|
|
16
|
+
# Reuses StoreProvisioning.load_projects (rescues to {} so a malformed projects.yml never
|
|
17
|
+
# raises) instead of writing a third copy of that reader (a second copy already exists in
|
|
18
|
+
# QmdSync, out of scope here).
|
|
19
|
+
#
|
|
20
|
+
# Pure filesystem, dependency-injected: `discover` takes `plastic_home` as its only
|
|
21
|
+
# argument, performs no writes, no `system`/`spawn`, no network, no eval, no
|
|
22
|
+
# ENV/global-constant reads.
|
|
23
|
+
module StoreDiscovery
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Returns { stores: [ { key:, slug:, root:, store:, index: } ... ],
|
|
27
|
+
# missing: [ { slug:, project_dir: } ... ] }.
|
|
28
|
+
#
|
|
29
|
+
# `stores` entries: `key` is "global" or "project:<slug>" (the store_index/referer_store
|
|
30
|
+
# key shape GraphRebuild and the doctor checks already use); `slug` is the bare token
|
|
31
|
+
# form used in a cross-store ref ("global", "knowdb", "ai-agents-resources"); `root` is
|
|
32
|
+
# the directory holding INDEX.md; `store` is the intents directory; `index` is the
|
|
33
|
+
# INDEX.md path. Sorted by slug (global first) for deterministic output.
|
|
34
|
+
#
|
|
35
|
+
# `missing` lists every projects.yml slug with no `store/` directory on disk: legal
|
|
36
|
+
# (plastic-store-provisioning exists for exactly this state), reported so callers never
|
|
37
|
+
# mistake it for a store with zero intents.
|
|
38
|
+
def discover(plastic_home)
|
|
39
|
+
stores = []
|
|
40
|
+
missing = []
|
|
41
|
+
|
|
42
|
+
global_store = File.join(plastic_home, "store")
|
|
43
|
+
if File.directory?(global_store)
|
|
44
|
+
stores << { key: "global", slug: "global", root: plastic_home,
|
|
45
|
+
store: global_store, index: File.join(plastic_home, "INDEX.md") }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
registered = StoreProvisioning.load_projects(plastic_home) # { slug => info }, {} on error/absence
|
|
49
|
+
projects_root = File.join(plastic_home, "projects")
|
|
50
|
+
on_disk = File.directory?(projects_root) ? Dir.children(projects_root).reject { |e| e.start_with?(".") } : []
|
|
51
|
+
|
|
52
|
+
all_slugs = (registered.keys + on_disk).uniq.sort
|
|
53
|
+
|
|
54
|
+
all_slugs.each do |slug|
|
|
55
|
+
root = File.join(projects_root, slug)
|
|
56
|
+
store_dir = File.join(root, "store")
|
|
57
|
+
if File.directory?(store_dir)
|
|
58
|
+
stores << { key: "project:#{slug}", slug: slug, root: root,
|
|
59
|
+
store: store_dir, index: File.join(root, "INDEX.md") }
|
|
60
|
+
elsif registered.key?(slug)
|
|
61
|
+
missing << { slug: slug, project_dir: root }
|
|
62
|
+
end
|
|
63
|
+
# else: an on-disk directory with no store/ and no projects.yml entry (a junk dir,
|
|
64
|
+
# e.g. a stale path-as-slug from a past bug). Silently excluded, exactly as doctor's
|
|
65
|
+
# existing disk scan already does: it is neither a store nor a registered project.
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
{ stores: stores, missing: missing }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Convenience: just the known store SLUGS (the token form used in a cross-store ref),
|
|
72
|
+
# for IntentValidator's injected known-store check (ACTION_7). "global" is included
|
|
73
|
+
# when the global store exists.
|
|
74
|
+
def known_slugs(plastic_home)
|
|
75
|
+
discover(plastic_home)[:stores].map { |s| s[:slug] }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -44,24 +44,33 @@ module StoreProvisioning
|
|
|
44
44
|
}
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
index_template = File.join(package_root, "templates", "index.md")
|
|
48
|
+
project_template = File.join(package_root, "templates", "project.yml")
|
|
49
|
+
|
|
50
|
+
missing = []
|
|
51
|
+
missing << "templates/index.md" unless File.exist?(index_template)
|
|
52
|
+
missing << "templates/project.yml" unless File.exist?(project_template)
|
|
53
|
+
|
|
54
|
+
unless missing.empty?
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: "cannot provision project '#{slug}': missing required " \
|
|
58
|
+
"template(s) #{missing.join(", ")} under #{package_root}/templates. " \
|
|
59
|
+
"This means the installer did not ship these templates to " \
|
|
60
|
+
"package_root/templates: check InstallerCore#core_files registers " \
|
|
61
|
+
"every templates/* file, then re-run the Plastic installer (or " \
|
|
62
|
+
"'plastic update') so package_root has current templates.",
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
47
66
|
project_dir = File.join(plastic_home, "projects", slug)
|
|
48
67
|
store_dir = File.join(project_dir, "store")
|
|
49
68
|
FileUtils.mkdir_p(store_dir)
|
|
50
69
|
|
|
51
70
|
created = []
|
|
52
71
|
created << write_if_missing(File.join(store_dir, ".gitkeep"), "")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if File.exist?(index_template)
|
|
56
|
-
created << write_if_missing(File.join(project_dir, "INDEX.md"),
|
|
57
|
-
File.read(index_template))
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
project_template = File.join(package_root, "templates", "project.yml")
|
|
61
|
-
if File.exist?(project_template)
|
|
62
|
-
created << write_if_missing(File.join(project_dir, "project.yml"),
|
|
63
|
-
File.read(project_template))
|
|
64
|
-
end
|
|
72
|
+
created << write_if_missing(File.join(project_dir, "INDEX.md"), File.read(index_template))
|
|
73
|
+
created << write_if_missing(File.join(project_dir, "project.yml"), File.read(project_template))
|
|
65
74
|
|
|
66
75
|
{ ok: true, store_dir: store_dir, created: created.compact }
|
|
67
76
|
end
|
package/scripts/new-intent
CHANGED
|
@@ -29,6 +29,7 @@ require_relative "lib/intent_validator"
|
|
|
29
29
|
require_relative "lib/graph_rebuild"
|
|
30
30
|
require_relative "lib/links_projection"
|
|
31
31
|
require_relative "lib/links_section"
|
|
32
|
+
require_relative "lib/store_discovery"
|
|
32
33
|
|
|
33
34
|
# --- Explicit flag parsing (no eval, no global injection) ------------------
|
|
34
35
|
|
|
@@ -162,17 +163,12 @@ def store_context(store)
|
|
|
162
163
|
end
|
|
163
164
|
end
|
|
164
165
|
|
|
165
|
-
# The in-scope stores under `plastic_home`, each { key:, store: }.
|
|
166
|
-
#
|
|
166
|
+
# The in-scope stores under `plastic_home`, each { key:, store: }. Delegates to
|
|
167
|
+
# StoreDiscovery, the single source of truth also used by project-links, rebuild-graph,
|
|
168
|
+
# and doctor.rb (intent 189), so cross-store resolution spans EVERY real store, not a
|
|
169
|
+
# hardcoded few.
|
|
167
170
|
def family_stores(plastic_home)
|
|
168
|
-
|
|
169
|
-
global_store = File.join(plastic_home, "store")
|
|
170
|
-
list << { key: "global", store: global_store } if File.directory?(global_store)
|
|
171
|
-
%w[plastic knowdb].each do |slug|
|
|
172
|
-
store = File.join(plastic_home, "projects", slug, "store")
|
|
173
|
-
list << { key: "project:#{slug}", store: store } if File.directory?(store)
|
|
174
|
-
end
|
|
175
|
-
list
|
|
171
|
+
StoreDiscovery.discover(plastic_home)[:stores].map { |s| { key: s[:key], store: s[:store] } }
|
|
176
172
|
end
|
|
177
173
|
|
|
178
174
|
# Build the cross-store maps the LinksProjection resolver needs:
|
|
@@ -353,8 +349,10 @@ def main(argv)
|
|
|
353
349
|
# fire adds nothing.
|
|
354
350
|
Bridge.append_savepoint(intent_dir, intent_file)
|
|
355
351
|
|
|
356
|
-
# 7. Self-validate (frontmatter + sanctioned sections)
|
|
357
|
-
|
|
352
|
+
# 7. Self-validate (frontmatter + sanctioned sections), including that every
|
|
353
|
+
# sources/chain cross-store token names a real store (intent 189 D3).
|
|
354
|
+
known_stores = StoreDiscovery.known_slugs(plastic_home)
|
|
355
|
+
result = IntentValidator.validate(intent_dir, known_stores: known_stores)
|
|
358
356
|
unless result[:ok]
|
|
359
357
|
warn "new-intent: scaffolded intent is NOT born complete:"
|
|
360
358
|
result[:missing].each { |f| warn " missing field: #{f}" }
|