@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
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
|
|
4
4
|
require_relative "qmd_sync"
|
|
5
5
|
|
|
6
|
-
# PowerTools
|
|
7
|
-
# (intent 66b; demoted to recommendations in intent 108, D8
|
|
8
|
-
# deterministic detection of each tool and builds a
|
|
9
|
-
# whichever tools are present, so the agent is
|
|
10
|
-
# them: QMD for finding intents,
|
|
6
|
+
# PowerTools - detect-then-degrade harness for Plastic's optional power-tools
|
|
7
|
+
# (intent 66b; demoted to recommendations in intent 108, D8; Enola added in
|
|
8
|
+
# intent 187). It owns deterministic detection of each tool and builds a
|
|
9
|
+
# RECOMMENDATION string for whichever tools are present, so the agent is
|
|
10
|
+
# reminded (not obliged) to prefer them: QMD for finding intents, Enola or
|
|
11
|
+
# Serena for code navigation.
|
|
11
12
|
#
|
|
12
13
|
# Strictly detect-then-degrade: a tool that is absent contributes nothing, and
|
|
13
14
|
# `mandate` returns nil when no tool is present. Nothing here installs anything.
|
|
14
15
|
#
|
|
15
16
|
# Pure and dependency-injected: every detection runs through an injected callable
|
|
16
|
-
# or keyword probe (PATH scan /
|
|
17
|
+
# or keyword probe (PATH scan / marker-directory walk), so the whole module is
|
|
17
18
|
# unit-testable with no real binaries, no network, and no global/ENV state.
|
|
18
19
|
module PowerTools
|
|
19
20
|
module_function
|
|
@@ -31,6 +32,15 @@ module PowerTools
|
|
|
31
32
|
!!path_probe.call
|
|
32
33
|
end
|
|
33
34
|
|
|
35
|
+
# True when Enola is present: a `.enola` directory exists in cwd or any
|
|
36
|
+
# ancestor (a generated snapshot), OR `enola` is resolvable on PATH. Both
|
|
37
|
+
# probes are injectable so tests do not depend on the host having Enola
|
|
38
|
+
# installed or indexed (intent 187).
|
|
39
|
+
def enola?(cwd:, path_probe: method(:which_enola), marker_finder: method(:enola_marker?))
|
|
40
|
+
return true if marker_finder.call(cwd)
|
|
41
|
+
!!path_probe.call
|
|
42
|
+
end
|
|
43
|
+
|
|
34
44
|
# True when `serena` is an executable on PATH. Mirrors QmdSync.which_qmd.
|
|
35
45
|
def which_serena
|
|
36
46
|
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
@@ -39,6 +49,14 @@ module PowerTools
|
|
|
39
49
|
end
|
|
40
50
|
end
|
|
41
51
|
|
|
52
|
+
# True when `enola` is an executable on PATH. Mirrors which_serena.
|
|
53
|
+
def which_enola
|
|
54
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
55
|
+
candidate = File.join(dir, "enola")
|
|
56
|
+
File.file?(candidate) && File.executable?(candidate)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
42
60
|
# Walk up from cwd to the filesystem root, returning true if any level holds a
|
|
43
61
|
# `.serena` directory.
|
|
44
62
|
def serena_marker?(cwd)
|
|
@@ -52,26 +70,51 @@ module PowerTools
|
|
|
52
70
|
false
|
|
53
71
|
end
|
|
54
72
|
|
|
73
|
+
# Walk up from cwd to the filesystem root, returning true if any level holds
|
|
74
|
+
# an `.enola` directory (a generated snapshot).
|
|
75
|
+
def enola_marker?(cwd)
|
|
76
|
+
dir = File.expand_path(cwd)
|
|
77
|
+
loop do
|
|
78
|
+
return true if Dir.exist?(File.join(dir, ".enola"))
|
|
79
|
+
parent = File.dirname(dir)
|
|
80
|
+
break if parent == dir
|
|
81
|
+
dir = parent
|
|
82
|
+
end
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
55
86
|
QMD_OBLIGATION = "prefer `qmd search` / `qmd query` over the `plastic-*` " \
|
|
56
87
|
"collections to check for existing or related intents before " \
|
|
57
88
|
"treating work as new"
|
|
58
89
|
SERENA_OBLIGATION = "prefer its symbolic tools (find_symbol / get_symbols_overview / " \
|
|
59
90
|
"find_referencing_symbols) for code navigation"
|
|
91
|
+
ENOLA_OBLIGATION = "prefer its MCP symbol resolution (or `.enola/facts.jsonl`) for " \
|
|
92
|
+
"code navigation over grep"
|
|
60
93
|
|
|
61
94
|
# Recommendation text for whichever tools are present, or nil when none are.
|
|
62
|
-
#
|
|
63
|
-
# embedded newline); one present returns that tool's own
|
|
64
|
-
# returns nil.
|
|
65
|
-
|
|
95
|
+
# QMD plus a code-navigation tool collapse to ONE combined line naming both
|
|
96
|
+
# obligations (no embedded newline); one tool present returns that tool's own
|
|
97
|
+
# line; neither returns nil.
|
|
98
|
+
#
|
|
99
|
+
# Enola-first: Enola and Serena share ONE code-navigation slot. When both are
|
|
100
|
+
# present, only Enola is named (intent 187, matching the owner's standing
|
|
101
|
+
# Enola-first ruling and avoiding a bloated three-tool line). The QMD-only and
|
|
102
|
+
# Serena-only lines are unchanged from before Enola existed.
|
|
103
|
+
def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil, enola_detector: nil)
|
|
66
104
|
qmd_present = qmd?(detector: qmd_detector)
|
|
105
|
+
enola_present = enola_detector ? !!enola_detector.call : enola?(cwd: cwd)
|
|
67
106
|
serena_present = serena_detector ? !!serena_detector.call : serena?(cwd: cwd)
|
|
68
107
|
|
|
69
|
-
|
|
70
|
-
|
|
108
|
+
nav_present = enola_present || serena_present
|
|
109
|
+
nav_name = enola_present ? "Enola" : "Serena"
|
|
110
|
+
nav_obligation = enola_present ? ENOLA_OBLIGATION : SERENA_OBLIGATION
|
|
111
|
+
|
|
112
|
+
if qmd_present && nav_present
|
|
113
|
+
"QMD and #{nav_name} are available: #{QMD_OBLIGATION}, and #{nav_obligation}."
|
|
71
114
|
elsif qmd_present
|
|
72
115
|
"QMD is available: #{QMD_OBLIGATION}."
|
|
73
|
-
elsif
|
|
74
|
-
"
|
|
116
|
+
elsif nav_present
|
|
117
|
+
"#{nav_name} is available: #{nav_obligation}."
|
|
75
118
|
end
|
|
76
119
|
end
|
|
77
120
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
# ProjectValidator - the single source of truth for "is a project spawn
|
|
7
|
+
# complete?" (intent 190).
|
|
8
|
+
#
|
|
9
|
+
# A project can be registered in projects.yml, have a store, and still be
|
|
10
|
+
# missing the pieces a real project needs: project.yml, a root AGENTS.md.
|
|
11
|
+
# The intent-26 spawn shipped exactly that shape and was caught only by a
|
|
12
|
+
# much later, pull-only plastic-doctor sweep. This module lets
|
|
13
|
+
# plastic-project-creating verify a spawn BEFORE announcing it as done,
|
|
14
|
+
# mirroring how scripts/new-intent already runs IntentValidator before
|
|
15
|
+
# announcing a new intent (validate-intent).
|
|
16
|
+
#
|
|
17
|
+
# Pure and dependency-injected: validate accepts an injectable plastic_home,
|
|
18
|
+
# uses no eval, performs no file writes, no global-constant injection.
|
|
19
|
+
# scripts/doctor.rb's check_project_store already covers 4 of these 6
|
|
20
|
+
# invariants (project_dir_exists, project_store_dir, project_index,
|
|
21
|
+
# project_yml_exists) plus cross_references, advisorially and pull-only;
|
|
22
|
+
# doctor adopting this module is a named follow-up, not part of this intent
|
|
23
|
+
# (D8). Invariant 4 (project-root AGENTS.md) has no existing check anywhere.
|
|
24
|
+
module ProjectValidator
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def validate(slug, plastic_home: File.join(Dir.home, ".plastic"))
|
|
28
|
+
missing = []
|
|
29
|
+
errors = []
|
|
30
|
+
|
|
31
|
+
entry = registration_for(slug, plastic_home)
|
|
32
|
+
unless entry
|
|
33
|
+
missing << "projects.yml registration"
|
|
34
|
+
errors << "project '#{slug}' is not registered in projects.yml with a 'path' key"
|
|
35
|
+
return { ok: false, missing: missing, errors: errors }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
project_path = entry["path"].to_s
|
|
39
|
+
|
|
40
|
+
# Invariant 2: registered project directory exists on disk.
|
|
41
|
+
unless File.directory?(project_path)
|
|
42
|
+
missing << "project directory"
|
|
43
|
+
errors << "registered project directory does not exist: #{project_path}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
project_dir = File.join(plastic_home, "projects", slug)
|
|
47
|
+
|
|
48
|
+
# Invariant 3: project.yml exists AND parses as YAML.
|
|
49
|
+
project_yml_path = File.join(project_dir, "project.yml")
|
|
50
|
+
if File.exist?(project_yml_path)
|
|
51
|
+
parsed = begin
|
|
52
|
+
YAML.safe_load(File.read(project_yml_path))
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
unless parsed.is_a?(Hash)
|
|
57
|
+
missing << "project.yml (valid YAML)"
|
|
58
|
+
errors << "project.yml exists at #{project_yml_path} but does not parse as YAML"
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
missing << "project.yml"
|
|
62
|
+
errors << "project.yml missing at #{project_yml_path}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Invariant 4: project-root AGENTS.md (the registered path, NOT
|
|
66
|
+
# ~/.plastic/projects/{slug}/). This is the intent-26 spawn's gap,
|
|
67
|
+
# uncaught by doctor.rb today.
|
|
68
|
+
agents_md_path = File.join(project_path, "AGENTS.md")
|
|
69
|
+
unless File.exist?(agents_md_path)
|
|
70
|
+
missing << "AGENTS.md (project root)"
|
|
71
|
+
errors << "AGENTS.md missing at project root: #{agents_md_path}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Invariant 5: store/ exists.
|
|
75
|
+
store_dir = File.join(project_dir, "store")
|
|
76
|
+
unless File.directory?(store_dir)
|
|
77
|
+
missing << "store/"
|
|
78
|
+
errors << "store directory missing: #{store_dir}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Invariant 6: INDEX.md exists.
|
|
82
|
+
index_md_path = File.join(project_dir, "INDEX.md")
|
|
83
|
+
unless File.exist?(index_md_path)
|
|
84
|
+
missing << "INDEX.md"
|
|
85
|
+
errors << "INDEX.md missing: #{index_md_path}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{ ok: missing.empty?, missing: missing, errors: errors }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Invariant 1: registered in projects.yml with a 'path'. Returns the
|
|
92
|
+
# project's entry Hash, or nil when unregistered or the entry has no path.
|
|
93
|
+
def registration_for(slug, plastic_home)
|
|
94
|
+
projects = load_projects(plastic_home)
|
|
95
|
+
entry = projects[slug]
|
|
96
|
+
entry.is_a?(Hash) && entry["path"] ? entry : nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Parse projects.yml -> the `projects` Hash, or {} on any error/absence.
|
|
100
|
+
# Mirrors StoreProvisioning.load_projects.
|
|
101
|
+
def load_projects(plastic_home)
|
|
102
|
+
path = File.join(plastic_home, "projects.yml")
|
|
103
|
+
return {} unless File.exist?(path)
|
|
104
|
+
|
|
105
|
+
data = begin
|
|
106
|
+
YAML.safe_load(File.read(path)) || {}
|
|
107
|
+
rescue StandardError
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
projects = data.is_a?(Hash) ? data["projects"] : nil
|
|
111
|
+
projects.is_a?(Hash) ? projects : {}
|
|
112
|
+
end
|
|
113
|
+
end
|
package/scripts/lib/qmd_hook.rb
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
require_relative "qmd_sync"
|
|
5
5
|
require_relative "power_tools"
|
|
6
6
|
|
|
7
|
-
# QmdHook
|
|
7
|
+
# QmdHook - decision logic for the power-tools UserPromptSubmit hook (intents 66,
|
|
8
8
|
# 66b). Pure and dependency-injected: returns the additionalContext string to
|
|
9
9
|
# emit, or nil to emit nothing. The executable hook wires real deps and prints;
|
|
10
10
|
# this is unit-tested with a fake runner/detector (no real qmd, no network).
|
|
11
11
|
#
|
|
12
12
|
# When qmd is present it still injects scored qmd hits (intent 66), then appends
|
|
13
|
-
# the PowerTools mandate (a
|
|
14
|
-
# intents,
|
|
13
|
+
# the PowerTools mandate (a recommendation per present tool: qmd for finding
|
|
14
|
+
# intents, Enola-first for code navigation, falling back to Serena; intent 187
|
|
15
|
+
# added the enola_detector alongside the pre-existing serena_detector).
|
|
15
16
|
module QmdHook
|
|
16
17
|
module_function
|
|
17
18
|
|
|
@@ -19,11 +20,13 @@ module QmdHook
|
|
|
19
20
|
|
|
20
21
|
def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
|
|
21
22
|
detector: QmdSync.method(:detect), limit: 3, min_score: 0.5,
|
|
22
|
-
serena_detector: nil)
|
|
23
|
+
serena_detector: nil, enola_detector: nil)
|
|
23
24
|
serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
|
|
25
|
+
enola_detector ||= -> { PowerTools.enola?(cwd: cwd) }
|
|
24
26
|
qmd_present = !!detector.call
|
|
25
27
|
serena_present = !!serena_detector.call
|
|
26
|
-
|
|
28
|
+
enola_present = !!enola_detector.call
|
|
29
|
+
return nil unless qmd_present || serena_present || enola_present
|
|
27
30
|
|
|
28
31
|
p = prompt.to_s.strip
|
|
29
32
|
# The hit SEARCH is the only expensive step and the only one gated by prompt
|
|
@@ -37,19 +40,20 @@ module QmdHook
|
|
|
37
40
|
hits = QmdSync.search(p, collections: collections, limit: limit,
|
|
38
41
|
min_score: min_score, runner: runner, detector: detector)
|
|
39
42
|
if hits.any?
|
|
40
|
-
parts << "Related / prior Plastic intents (qmd BM25, includes completed)
|
|
43
|
+
parts << "Related / prior Plastic intents (qmd BM25, includes completed) - " \
|
|
41
44
|
"check before treating this as new work:"
|
|
42
45
|
hits.each do |h|
|
|
43
46
|
loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
|
|
44
47
|
pct = (h[:score] * 100).round
|
|
45
|
-
parts << "- [#{pct}%] #{loc}
|
|
48
|
+
parts << "- [#{pct}%] #{loc} - #{h[:title]}"
|
|
46
49
|
end
|
|
47
50
|
parts << ""
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
mandate = PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
|
|
52
|
-
serena_detector: -> { serena_present }
|
|
55
|
+
serena_detector: -> { serena_present },
|
|
56
|
+
enola_detector: -> { enola_present })
|
|
53
57
|
parts << mandate if mandate
|
|
54
58
|
return nil if parts.empty?
|
|
55
59
|
parts.join("\n")
|
|
@@ -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
|