@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.10
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.md +170 -469
- package/README.md +95 -58
- package/agents/plastic-brainstorming.md +45 -0
- package/agents/plastic-enforcer.md +36 -0
- package/agents/plastic-executor.md +47 -0
- package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
- package/agents/{intent-curator.md → plastic-intent-curator.md} +8 -6
- package/agents/plastic-planner.md +47 -0
- package/agents/plastic-spec-specialist.md +45 -0
- package/bin/plastic.js +57 -0
- package/bin/test +28 -0
- package/deprecations.yml +1 -10
- package/hooks/auto-arm +5 -0
- package/hooks/bash-gate +3 -0
- package/hooks/check-update +12 -8
- package/hooks/code-gate +12 -0
- package/hooks/create-gate +3 -0
- package/hooks/gate-check +3 -1
- package/hooks/hooks.json +52 -0
- package/hooks/qmd-search +8 -0
- package/hooks/statusline +150 -41
- package/package.json +2 -2
- package/scripts/agent-report +142 -0
- package/scripts/dashboard.rb +687 -0
- package/scripts/doctor.rb +1219 -621
- package/scripts/hook-auto-arm +51 -0
- package/scripts/hook-bash-gate +41 -0
- package/scripts/hook-code-gate +27 -0
- package/scripts/hook-continue +15 -114
- package/scripts/hook-create-gate +59 -0
- package/scripts/hook-gate-check +47 -32
- package/scripts/hook-qmd-search +44 -0
- package/scripts/hook-session-start +106 -38
- package/scripts/install.rb +91 -529
- package/scripts/lib/boot_banner.rb +28 -0
- package/scripts/lib/bridge.rb +452 -19
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +812 -0
- package/scripts/lib/intent_validator.rb +235 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/power_tools.rb +76 -0
- package/scripts/lib/qmd_hook.rb +57 -0
- package/scripts/lib/qmd_sync.rb +230 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +327 -0
- package/scripts/project-links +287 -0
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +139 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/select-update-target +93 -0
- package/scripts/spawn-preamble +138 -0
- package/scripts/uninstall.rb +53 -0
- package/scripts/update.rb +164 -0
- package/scripts/validate-intent +54 -0
- package/scripts/versions.rb +141 -0
- package/skills/_active-intent-gate.md +1 -1
- package/skills/add-project-store/SKILL.md +54 -0
- package/skills/auto/SKILL.md +87 -7
- package/skills/auto/evals/evals.json +255 -0
- package/skills/auto/references/agent-architecture.md +155 -0
- package/skills/auto/references/agent-report-contract.md +86 -0
- package/skills/brainstorming/SKILL.md +10 -9
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/brainstorming-grill-me/SKILL.md +6 -6
- package/skills/continuing/SKILL.md +99 -82
- package/skills/continuing/evals/evals.json +145 -0
- package/skills/continuing/references/context-management.md +32 -0
- package/skills/creating-intent/SKILL.md +82 -36
- package/skills/creating-intent/evals/evals.json +72 -0
- package/skills/creating-intent/references/lifecycle.md +81 -0
- package/skills/creating-intent/references/wikilinks.md +8 -0
- package/skills/creating-project/SKILL.md +40 -8
- package/skills/creating-project/references/hubs-projects.md +55 -0
- package/skills/dashboard/SKILL.md +126 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/dashboard/templates/dashboard-global.md +31 -0
- package/skills/dashboard/templates/dashboard-project.md +40 -0
- package/skills/doctor/SKILL.md +51 -4
- package/skills/doctor/references/gates-stuck-detection.md +38 -0
- package/skills/doctor/report.md +4 -0
- package/skills/evaluating-skills/SKILL.md +140 -0
- package/skills/evaluating-skills/assets/eval-template.json +12 -0
- package/skills/evaluating-skills/evals/evals.json +75 -0
- package/skills/evaluating-skills/references/convention-checks.md +76 -0
- package/skills/evaluating-skills/references/eval-methodology.md +154 -0
- package/skills/executing-plan/SKILL.md +5 -3
- package/skills/install/SKILL.md +69 -8
- package/skills/intent-curator/SKILL.md +6 -4
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +22 -7
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +45 -0
- package/skills/managing-index/SKILL.md +11 -1
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +7 -2
- package/skills/releasing/SKILL.md +80 -23
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +10 -2
- package/skills/research/evals/evals.json +22 -0
- package/skills/savepoint/SKILL.md +46 -37
- package/skills/savepoint/references/context-management.md +32 -0
- package/skills/uninstall/SKILL.md +39 -28
- package/skills/update/SKILL.md +41 -44
- package/skills/versions/SKILL.md +65 -0
- package/skills/writing-instructions/SKILL.md +159 -0
- package/skills/writing-instructions/references/agentskills-spec.md +135 -0
- package/skills/writing-plans/SKILL.md +5 -5
- package/templates/agents.md +7 -7
- package/templates/outcome.md +13 -0
- package/templates/savepoint.md +14 -13
- package/templates/spec.md +25 -0
- package/bin/install.js +0 -29
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "date"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
# IntentValidator — the single source of truth for "is an intent born complete?"
|
|
9
|
+
# (intent 60).
|
|
10
|
+
#
|
|
11
|
+
# An intent is born complete when its frontmatter carries every required field
|
|
12
|
+
# and its `sources` and `chain` are well-formed arrays of Folgezettel id references
|
|
13
|
+
# (bare ids like `1a2`, or cross-store refs like `global:1a2`; integer ids are coerced).
|
|
14
|
+
# This module is the only definition of that contract; the `validate-intent` CLI,
|
|
15
|
+
# the doctor diagnostics, and the creating-intent skill all consult it so the
|
|
16
|
+
# definition never drifts across copies.
|
|
17
|
+
#
|
|
18
|
+
# Pure and dependency-injected: `validate` accepts an injectable `plastic_home`
|
|
19
|
+
# for house-style parity, parses with a rescue-to-safe-default reader, uses no
|
|
20
|
+
# `eval`, and performs no file writes and no global-constant injection.
|
|
21
|
+
module IntentValidator
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Must match Doctor::REQUIRED_FRONTMATTER_FIELDS (scripts/doctor.rb).
|
|
25
|
+
REQUIRED_FIELDS = %w[id intent sources chain created author tags].freeze
|
|
26
|
+
|
|
27
|
+
# Fields whose value must be a well-formed array of valid id strings.
|
|
28
|
+
ARRAY_ID_FIELDS = %w[sources chain].freeze
|
|
29
|
+
|
|
30
|
+
# Sanctioned top-level intent sections, in order (intent 60b). The only
|
|
31
|
+
# sanctioned `###` subsection is `### Decisions`, which is OPTIONAL (added after
|
|
32
|
+
# brainstorming) and therefore never flagged as missing. This is the single
|
|
33
|
+
# definition shared by the create gate, the validate-intent CLI, and doctor.
|
|
34
|
+
SANCTIONED_SECTIONS = ["## Intent", "## Context", "## Outcome", "## Insights", "## Links"].freeze
|
|
35
|
+
|
|
36
|
+
# Folgezettel id form: digits then an optional lowercase-letter/digit suffix
|
|
37
|
+
# (for example "14", "14a", "4a1"). Mirrors scripts/folgezettel-id.
|
|
38
|
+
ID_PATTERN = /\A([a-z0-9-]+:)?\d+[a-z0-9]*\z/
|
|
39
|
+
|
|
40
|
+
# True iff `value` is a String matching the Folgezettel id form.
|
|
41
|
+
def valid_id?(value)
|
|
42
|
+
value.to_s.match?(ID_PATTERN)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Read a file's YAML frontmatter, returning the parsed Hash (or {} when the
|
|
46
|
+
# frontmatter block is empty), or nil when there is no parseable frontmatter.
|
|
47
|
+
# A copy of Doctor#parse_frontmatter so `created:` dates do not crash.
|
|
48
|
+
def parse_frontmatter(path)
|
|
49
|
+
return nil unless File.exist?(path)
|
|
50
|
+
|
|
51
|
+
parse_frontmatter_text(File.read(path))
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# PURE: parse YAML frontmatter from a content STRING (no file IO). Returns the
|
|
57
|
+
# parsed Hash, {} for an empty block, or nil when there is no parseable block.
|
|
58
|
+
def parse_frontmatter_text(content)
|
|
59
|
+
return nil unless content.is_a?(String) && content.start_with?("---")
|
|
60
|
+
|
|
61
|
+
parts = content.split("---", 3)
|
|
62
|
+
return nil if parts.length < 3
|
|
63
|
+
|
|
64
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
|
|
65
|
+
rescue StandardError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# PURE: strip the leading YAML frontmatter block from a content STRING,
|
|
70
|
+
# returning the body text (everything after the closing `---`). When there is
|
|
71
|
+
# no frontmatter block, the whole content is the body.
|
|
72
|
+
def body_of(content)
|
|
73
|
+
return "" unless content.is_a?(String)
|
|
74
|
+
return content unless content.start_with?("---")
|
|
75
|
+
|
|
76
|
+
parts = content.split("---", 3)
|
|
77
|
+
parts.length < 3 ? content : parts[2]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# PURE: given the intent file body text, return sanctioned-section findings.
|
|
81
|
+
# Flags any unknown top-level `## ` heading and any missing sanctioned section.
|
|
82
|
+
# Ignores `### ` subsections entirely (Decisions is optional and lives under
|
|
83
|
+
# Context). Returns { ok:, missing: [section names], unknown: [heading strings] }.
|
|
84
|
+
def validate_sections(body)
|
|
85
|
+
headings = body.to_s.lines.filter_map do |l|
|
|
86
|
+
s = l.strip
|
|
87
|
+
s if s.start_with?("## ") && !s.start_with?("### ")
|
|
88
|
+
end
|
|
89
|
+
present = headings & SANCTIONED_SECTIONS
|
|
90
|
+
missing = SANCTIONED_SECTIONS - present
|
|
91
|
+
unknown = headings - SANCTIONED_SECTIONS
|
|
92
|
+
{ ok: missing.empty? && unknown.empty?, missing: missing, unknown: unknown }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# PURE: given a parsed frontmatter Hash (or nil), return
|
|
96
|
+
# { ok: Boolean, missing: [field names], errors: [human strings] }.
|
|
97
|
+
def validate_frontmatter(fm)
|
|
98
|
+
unless fm.is_a?(Hash)
|
|
99
|
+
return { ok: false, missing: REQUIRED_FIELDS.dup, errors: ["no frontmatter found"] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
missing = REQUIRED_FIELDS.reject { |f| fm.key?(f) }
|
|
103
|
+
errors = missing.map { |f| "missing required field: #{f}" }
|
|
104
|
+
|
|
105
|
+
ARRAY_ID_FIELDS.each do |key|
|
|
106
|
+
next unless fm.key?(key)
|
|
107
|
+
|
|
108
|
+
value = fm[key]
|
|
109
|
+
unless value.is_a?(Array)
|
|
110
|
+
errors << "#{key} must be an array"
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
value.each do |element|
|
|
115
|
+
errors << "#{key} has invalid id: #{element.inspect}" unless valid_id?(element)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
{ ok: missing.empty? && errors.empty?, missing: missing, errors: errors }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# PURE: combine the frontmatter result with section-structure findings for a
|
|
123
|
+
# content STRING. Returns the frontmatter result hash extended with
|
|
124
|
+
# :section_missing, :section_unknown, and folded section errors; :ok is the AND
|
|
125
|
+
# of frontmatter and sections. Lets the create gate validate proposed content
|
|
126
|
+
# (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))
|
|
129
|
+
sections = validate_sections(body_of(content))
|
|
130
|
+
merge_sections(fm_result, sections)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Fold section findings into a frontmatter result hash (shared by validate and
|
|
134
|
+
# validate_content). Does not mutate the input.
|
|
135
|
+
def merge_sections(fm_result, sections)
|
|
136
|
+
errors = fm_result[:errors].dup
|
|
137
|
+
sections[:unknown].each { |h| errors << "unknown section: #{h}" }
|
|
138
|
+
sections[:missing].each { |s| errors << "missing required section: #{s}" }
|
|
139
|
+
{
|
|
140
|
+
ok: fm_result[:ok] && sections[:ok],
|
|
141
|
+
missing: fm_result[:missing],
|
|
142
|
+
errors: errors,
|
|
143
|
+
section_missing: sections[:missing],
|
|
144
|
+
section_unknown: sections[:unknown],
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Resolve an intent directory's primary md file and validate its frontmatter
|
|
149
|
+
# AND its sanctioned section structure. `plastic_home` is accepted for
|
|
150
|
+
# house-style parity (injectable) even though validation reads the dir directly.
|
|
151
|
+
def validate(intent_dir, plastic_home: File.join(Dir.home, ".plastic"))
|
|
152
|
+
md_path = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
|
|
153
|
+
content = File.exist?(md_path) ? File.read(md_path) : nil
|
|
154
|
+
validate_content(content)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# PURE: cross-intent graph-shape invariants (intent 68). These need visibility
|
|
158
|
+
# over the whole intent set, so they live apart from the single-file born-complete
|
|
159
|
+
# helpers above (which must not drift). No file IO: the caller builds `nodes`.
|
|
160
|
+
#
|
|
161
|
+
# `nodes` is a Hash { id(String) => { sources: [ids], chain: [ids] } } for every
|
|
162
|
+
# intent in ONE store's id space. Returns { i1: [...], i3: [...], i4: [...] },
|
|
163
|
+
# each an array of human-readable finding strings.
|
|
164
|
+
#
|
|
165
|
+
# I2 (no false symmetry) is INTENTIONALLY not computed: a relational `chain` entry
|
|
166
|
+
# with no reciprocal `sources` is valid and must never be flagged.
|
|
167
|
+
def validate_graph(nodes)
|
|
168
|
+
nodes = normalize_nodes(nodes)
|
|
169
|
+
{ i1: graph_i1(nodes), i3: graph_i3(nodes), i4: graph_i4(nodes) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Coerce node arrays to deduped String id lists; tolerate missing keys.
|
|
173
|
+
def normalize_nodes(nodes)
|
|
174
|
+
return {} unless nodes.is_a?(Hash)
|
|
175
|
+
|
|
176
|
+
nodes.each_with_object({}) do |(id, edges), acc|
|
|
177
|
+
edges = {} unless edges.is_a?(Hash)
|
|
178
|
+
acc[id.to_s] = {
|
|
179
|
+
sources: Array(edges[:sources] || edges["sources"]).map(&:to_s).uniq,
|
|
180
|
+
chain: Array(edges[:chain] || edges["chain"]).map(&:to_s).uniq,
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# An id is a cross-store reference (out of this store's scope) when it carries a
|
|
186
|
+
# `<store>:` prefix, mirroring how `valid_id?` accepts the prefix. Such refs are
|
|
187
|
+
# resolved outside this node set, so they are never danglers here.
|
|
188
|
+
def cross_store_ref?(id)
|
|
189
|
+
id.to_s.include?(":")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# I1 (formative reciprocity): for every B and every `s` in B.sources that resolves
|
|
193
|
+
# in this store, B must appear in s.chain. A `s` that does not resolve is an I4
|
|
194
|
+
# dangler, not an I1 violation, so it is skipped here.
|
|
195
|
+
def graph_i1(nodes)
|
|
196
|
+
findings = []
|
|
197
|
+
nodes.each do |b_id, edges|
|
|
198
|
+
edges[:sources].each do |s|
|
|
199
|
+
next if cross_store_ref?(s)
|
|
200
|
+
next unless nodes.key?(s)
|
|
201
|
+
|
|
202
|
+
findings << "#{b_id}.sources lists #{s} but #{s}.chain is missing #{b_id}" unless nodes[s][:chain].include?(b_id)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
findings
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# I3 (per-node disjoint): X.sources and X.chain must not overlap.
|
|
209
|
+
def graph_i3(nodes)
|
|
210
|
+
findings = []
|
|
211
|
+
nodes.each do |x_id, edges|
|
|
212
|
+
(edges[:sources] & edges[:chain]).each do |overlap|
|
|
213
|
+
findings << "#{x_id} lists #{overlap} in BOTH sources and chain"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
findings
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# I4 (no danglers): every bare (same-store) id in any sources/chain must resolve
|
|
220
|
+
# to a node. Cross-store `<store>:<id>` refs resolve elsewhere and are not flagged.
|
|
221
|
+
def graph_i4(nodes)
|
|
222
|
+
findings = []
|
|
223
|
+
nodes.each do |id, edges|
|
|
224
|
+
%i[sources chain].each do |field|
|
|
225
|
+
edges[field].each do |ref|
|
|
226
|
+
next if cross_store_ref?(ref)
|
|
227
|
+
next if nodes.key?(ref)
|
|
228
|
+
|
|
229
|
+
findings << "#{id}.#{field} references #{ref} which resolves to no intent"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
findings
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# LinksProjection — pure logic that projects one intent's sources/chain graph into
|
|
5
|
+
# the canonical I5 `## Links` section text (intent 72). Mirrors the pure-module
|
|
6
|
+
# style of GraphRebuild: module-function helpers, no file IO, no eval, no
|
|
7
|
+
# ENV/global state. The IO shell (scripts/project-links) and doctor build the
|
|
8
|
+
# cross-store resolver and feed it here.
|
|
9
|
+
#
|
|
10
|
+
# Pinned canonical projection (the human's format call):
|
|
11
|
+
# 1. ORDERING (load-bearing): ALL sources first, in frontmatter order, THEN all
|
|
12
|
+
# chain, in frontmatter order. Sources can NEVER appear at the end. No group
|
|
13
|
+
# headings, no per-entry source/chain tags: the ordering carries the meaning.
|
|
14
|
+
# 2. ENTRY SHAPE: one list item `- [[<id>--<slug>|<target's full intent: text>]]`,
|
|
15
|
+
# where the wikilink TARGET is the target intent's resolvable `id--slug` file
|
|
16
|
+
# basename (so it clicks through in Obsidian) and the LABEL is the target's
|
|
17
|
+
# full `intent:` frontmatter text, whitespace-trimmed.
|
|
18
|
+
# 3. CROSS-STORE: a cross-store target renders
|
|
19
|
+
# `- [[<store>:<id>--<slug>|<target's full intent: text>]]`.
|
|
20
|
+
# 4. RESOLVER MISS: a ref that resolves to no intent raises UnresolvedRef. No
|
|
21
|
+
# bare-id, slug-less, or guessed link is ever emitted.
|
|
22
|
+
# 5. EMPTY-STATE: empty sources AND chain yields the heading plus a single
|
|
23
|
+
# explanatory comment.
|
|
24
|
+
module LinksProjection
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
HEADING = "## Links"
|
|
28
|
+
EMPTY_COMMENT = "<!-- No sources or chain; this intent has no graph edges to project. -->"
|
|
29
|
+
|
|
30
|
+
# Raised when a sources/chain ref resolves to no intent. Carries the offending
|
|
31
|
+
# ref so the IO shell can report it per-intent and skip the write.
|
|
32
|
+
class UnresolvedRef < StandardError
|
|
33
|
+
attr_reader :ref
|
|
34
|
+
|
|
35
|
+
def initialize(ref)
|
|
36
|
+
@ref = ref
|
|
37
|
+
super("unresolved sources/chain ref: #{ref.inspect}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# PURE. Build the canonical `## Links` section text for one intent.
|
|
42
|
+
#
|
|
43
|
+
# sources — array of id / `store:id` ref strings, in frontmatter order
|
|
44
|
+
# chain — array of id / `store:id` ref strings, in frontmatter order
|
|
45
|
+
# resolve — a callable (`->(ref) { ... }`) mapping ONE ref to a Hash like
|
|
46
|
+
# { target: "<id>--<slug>", label: "<full intent: text>" } or
|
|
47
|
+
# { target: "<store>:<id>--<slug>", label: "<full intent: text>" }.
|
|
48
|
+
# Returning nil (or a Hash lacking :target) signals no target and
|
|
49
|
+
# raises UnresolvedRef. Keeping resolution injected keeps this module
|
|
50
|
+
# pure and hermetically testable with in-memory maps.
|
|
51
|
+
#
|
|
52
|
+
# Returns the full section text: the `## Links` heading line, one entry line per
|
|
53
|
+
# ref (sources first, then chain), and a single trailing newline. The empty case
|
|
54
|
+
# returns the heading + the empty-state comment + a single trailing newline.
|
|
55
|
+
def section(sources:, chain:, resolve:)
|
|
56
|
+
src = Array(sources).map(&:to_s)
|
|
57
|
+
chn = Array(chain).map(&:to_s)
|
|
58
|
+
|
|
59
|
+
# Resolve EVERY ref to its { target:, label: } first, then dedup by the RESOLVED
|
|
60
|
+
# target (not the raw ref string). This is load-bearing: the same intent may be
|
|
61
|
+
# referenced as a bare id in one group and as `store:id` in another (or via a
|
|
62
|
+
# relocation), which dedups identically only AFTER resolution. Sources win
|
|
63
|
+
# (formative edge), and frontmatter order is preserved within each group.
|
|
64
|
+
seen = {}
|
|
65
|
+
rendered = []
|
|
66
|
+
src.each { |ref| add_entry(ref, resolve, seen, rendered) }
|
|
67
|
+
chn.each { |ref| add_entry(ref, resolve, seen, rendered) }
|
|
68
|
+
|
|
69
|
+
return empty_section if rendered.empty?
|
|
70
|
+
|
|
71
|
+
(["#{HEADING}\n"] + rendered.map { |line| "#{line}\n" }).join
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# PURE. The canonical empty-state section: heading + the single comment line.
|
|
75
|
+
def empty_section
|
|
76
|
+
"#{HEADING}\n#{EMPTY_COMMENT}\n"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Resolve `ref`, render its entry, and append it to `rendered` UNLESS its resolved
|
|
80
|
+
# target was already emitted (dedup by resolved target, first-seen wins so sources
|
|
81
|
+
# precede chain). Mutates `seen` and `rendered`. Raises UnresolvedRef on a miss.
|
|
82
|
+
def add_entry(ref, resolve, seen, rendered)
|
|
83
|
+
target, label = resolve_entry(ref, resolve)
|
|
84
|
+
return if seen.key?(target)
|
|
85
|
+
|
|
86
|
+
seen[target] = true
|
|
87
|
+
rendered << "- [[#{target}|#{label}]]"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# PURE. Resolve one ref to [target, label]. Raises UnresolvedRef when the
|
|
91
|
+
# resolver returns nothing usable.
|
|
92
|
+
def resolve_entry(ref, resolve)
|
|
93
|
+
resolved = resolve.call(ref)
|
|
94
|
+
target = resolved.is_a?(Hash) ? resolved[:target] || resolved["target"] : nil
|
|
95
|
+
raise UnresolvedRef, ref if target.nil? || target.to_s.strip.empty?
|
|
96
|
+
|
|
97
|
+
label = (resolved[:label] || resolved["label"]).to_s.strip
|
|
98
|
+
[target.to_s, label]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# PURE. Render one entry line `- [[<target>|<label>]]` from a single ref. Kept for
|
|
102
|
+
# callers/tests that render one entry; #section uses add_entry for dedup.
|
|
103
|
+
def entry(ref, resolve)
|
|
104
|
+
target, label = resolve_entry(ref, resolve)
|
|
105
|
+
"- [[#{target}|#{label}]]"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# PURE. Resolve ONE sources/chain ref to its `{ target:, label: }` projection,
|
|
109
|
+
# given the in-memory cross-store maps. This is the single resolver definition
|
|
110
|
+
# shared by the IO shell (scripts/project-links) and the doctor check, so the two
|
|
111
|
+
# can never diverge.
|
|
112
|
+
#
|
|
113
|
+
# ref — "40" (same-store bare id) or "knowdb:1" (cross-store)
|
|
114
|
+
# referer_store — store_key of the intent carrying the ref ("global" / "project:<slug>")
|
|
115
|
+
# relocation_map — from GraphRebuild.build_relocation_map (spans all stores)
|
|
116
|
+
# store_index — { store_key => [bare ids present] } (spans all stores)
|
|
117
|
+
# node_index — { store_key => { id => { basename:, label: } } } (spans all stores)
|
|
118
|
+
#
|
|
119
|
+
# Returns { target:, label: } (target is `<id>--<slug>` for a same-store id, or
|
|
120
|
+
# `<slug>:<id>--<slug>` for a cross-store one), or nil when the ref resolves to no
|
|
121
|
+
# live intent (which makes #section / #entry raise UnresolvedRef).
|
|
122
|
+
#
|
|
123
|
+
# Uses GraphRebuild.resolve_ref so a relocation always wins over a coincidentally
|
|
124
|
+
# reused id (the `global:24` impostor hazard), exactly as the frontmatter rebuild
|
|
125
|
+
# and the cross-store doctor check do.
|
|
126
|
+
def resolve_ref_projection(ref, referer_store:, relocation_map:, store_index:, node_index:)
|
|
127
|
+
require_relative "graph_rebuild"
|
|
128
|
+
|
|
129
|
+
res = GraphRebuild.resolve_ref(ref, referer_store: referer_store,
|
|
130
|
+
relocation_map: relocation_map,
|
|
131
|
+
store_index: store_index)
|
|
132
|
+
case res[:status]
|
|
133
|
+
when :same_store
|
|
134
|
+
node = (node_index[referer_store] || {})[res[:id]]
|
|
135
|
+
return nil if node.nil?
|
|
136
|
+
|
|
137
|
+
{ target: node[:basename], label: node[:label] }
|
|
138
|
+
when :cross_store
|
|
139
|
+
slug, bare = res[:ref].split(":", 2)
|
|
140
|
+
target_key = canonical_store_key(slug, store_index)
|
|
141
|
+
node = (node_index[target_key] || {})[bare]
|
|
142
|
+
return nil if node.nil?
|
|
143
|
+
|
|
144
|
+
{ target: "#{slug}:#{node[:basename]}", label: node[:label] }
|
|
145
|
+
else # :dead
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Map a ref store slug ("global", "knowdb", "plastic") to a node_index/store_index
|
|
151
|
+
# key ("global", "project:knowdb", "project:plastic"). Mirrors
|
|
152
|
+
# GraphRebuild.canonical_store_key's intent for the node_index keyspace.
|
|
153
|
+
def canonical_store_key(slug, store_index)
|
|
154
|
+
return "global" if slug == "global"
|
|
155
|
+
return slug if (store_index || {}).key?(slug)
|
|
156
|
+
|
|
157
|
+
projected = "project:#{slug}"
|
|
158
|
+
(store_index || {}).key?(projected) ? projected : slug
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "intent_validator"
|
|
5
|
+
|
|
6
|
+
# LinksSection — pure, minimal, style-preserving rewrite of ONLY the `## Links`
|
|
7
|
+
# section of an intent file's content string (intent 72). Mirrors the discipline of
|
|
8
|
+
# FrontmatterWriter: pure (no file IO, no eval, no global/ENV state), and returns
|
|
9
|
+
# the original content UNCHANGED when nothing changed (idempotency).
|
|
10
|
+
#
|
|
11
|
+
# `## Links` is canonically the LAST sanctioned section
|
|
12
|
+
# (IntentValidator::SANCTIONED_SECTIONS = Intent, Context, Outcome, Insights,
|
|
13
|
+
# Links), and real intents put it last. So a file with an existing `## Links` has
|
|
14
|
+
# its section replaced in place (from the heading to the next top-level `## `
|
|
15
|
+
# heading or EOF), and a file WITHOUT a `## Links` gets one appended at end-of-body,
|
|
16
|
+
# separated by exactly one blank line, preserving the body's trailing-newline shape.
|
|
17
|
+
#
|
|
18
|
+
# FENCE AWARENESS (intent 72 corruption fix): a `## Links` heading INSIDE a fenced
|
|
19
|
+
# code block (``` or ~~~, possibly with an info string like ```markdown) is part of
|
|
20
|
+
# an EXAMPLE, not a real section. All section scanning here IGNORES headings inside
|
|
21
|
+
# fences and only ever targets the REAL `## Links` section (outside any fence). The
|
|
22
|
+
# section end is the next `## ` heading that is ALSO outside a fence, so a replace
|
|
23
|
+
# never consumes or unbalances a code fence. If more than one REAL `## Links`
|
|
24
|
+
# heading exists, #rewrite raises AmbiguousLinks rather than guess.
|
|
25
|
+
#
|
|
26
|
+
# Frontmatter is NEVER touched: the leading `---`...`---` block is preserved
|
|
27
|
+
# byte-for-byte and only the body is rebuilt.
|
|
28
|
+
module LinksSection
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
HEADING = "## Links"
|
|
32
|
+
|
|
33
|
+
# A fence delimiter: ``` or ~~~ (any length >= 3), optional leading whitespace,
|
|
34
|
+
# optional info string (e.g. ```markdown). Mirrors CommonMark fenced-code rules
|
|
35
|
+
# closely enough for intent bodies.
|
|
36
|
+
FENCE_RE = /\A\s*(`{3,}|~{3,})/
|
|
37
|
+
|
|
38
|
+
# Raised when a body has more than one REAL `## Links` heading outside any fence;
|
|
39
|
+
# the tool must fail loud rather than guess which one to rewrite.
|
|
40
|
+
class AmbiguousLinks < StandardError
|
|
41
|
+
def initialize(count)
|
|
42
|
+
super("found #{count} real `## Links` headings outside code fences; refusing to guess")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# PURE. Replace (or insert) the REAL `## Links` section in `content` with
|
|
47
|
+
# `section_text` (the canonical block from LinksProjection.section, which begins
|
|
48
|
+
# with the `## Links` heading line and ends with a single trailing newline).
|
|
49
|
+
# Returns the new content, or the original when nothing changed. Raises
|
|
50
|
+
# AmbiguousLinks when more than one real `## Links` heading exists.
|
|
51
|
+
def rewrite(content, section_text)
|
|
52
|
+
return content unless content.is_a?(String)
|
|
53
|
+
|
|
54
|
+
fm, body = split_frontmatter(content)
|
|
55
|
+
new_body = rewrite_body(body, section_text)
|
|
56
|
+
updated = "#{fm}#{new_body}"
|
|
57
|
+
updated == content ? content : updated
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Split content into [frontmatter_with_delimiters, body]. When there is no
|
|
61
|
+
# frontmatter block, the frontmatter part is "" and the whole content is the
|
|
62
|
+
# body. The frontmatter part is preserved byte-for-byte by the caller.
|
|
63
|
+
def split_frontmatter(content)
|
|
64
|
+
return ["", content] unless content.start_with?("---")
|
|
65
|
+
|
|
66
|
+
parts = content.split("---", 3)
|
|
67
|
+
return ["", content] if parts.length < 3
|
|
68
|
+
|
|
69
|
+
["---#{parts[1]}---", parts[2]]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Rewrite ONLY the REAL `## Links` section within the body text.
|
|
73
|
+
def rewrite_body(body, section_text)
|
|
74
|
+
bounds = links_bounds(body)
|
|
75
|
+
if bounds
|
|
76
|
+
replace_section(body, section_text, bounds)
|
|
77
|
+
else
|
|
78
|
+
insert_section(body, section_text)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# PURE. Locate the REAL `## Links` section (fence-aware). Returns
|
|
83
|
+
# [start_index, end_index] line indices into body.lines, where start_index is the
|
|
84
|
+
# `## Links` heading line and end_index is the index of the next out-of-fence
|
|
85
|
+
# `## ` heading (or lines.length at EOF). Returns nil when there is no real
|
|
86
|
+
# `## Links` heading. Raises AmbiguousLinks when more than one exists.
|
|
87
|
+
def links_bounds(body)
|
|
88
|
+
lines = body.to_s.lines
|
|
89
|
+
starts = real_links_heading_indices(lines)
|
|
90
|
+
return nil if starts.empty?
|
|
91
|
+
raise AmbiguousLinks, starts.length if starts.length > 1
|
|
92
|
+
|
|
93
|
+
start = starts.first
|
|
94
|
+
stop = next_out_of_fence_heading(lines, start + 1)
|
|
95
|
+
[start, stop]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# PURE. Indices of every `## Links` heading line that is OUTSIDE any code fence.
|
|
99
|
+
# Accepts a body String or an Array of lines.
|
|
100
|
+
def real_links_heading_indices(body_or_lines)
|
|
101
|
+
lines = body_or_lines.is_a?(Array) ? body_or_lines : body_or_lines.to_s.lines
|
|
102
|
+
indices = []
|
|
103
|
+
in_fence = false
|
|
104
|
+
fence_marker = nil
|
|
105
|
+
lines.each_with_index do |line, i|
|
|
106
|
+
if (m = fence_open_close(line, in_fence, fence_marker))
|
|
107
|
+
in_fence = m[:in_fence]
|
|
108
|
+
fence_marker = m[:marker]
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
indices << i if !in_fence && line.rstrip == HEADING
|
|
112
|
+
end
|
|
113
|
+
indices
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# PURE. Index of the first `## ` heading at or after `from` that is OUTSIDE any
|
|
117
|
+
# code fence. Returns lines.length when none (EOF). Fence state is recomputed
|
|
118
|
+
# from the top so nested example fences after the real heading are respected.
|
|
119
|
+
def next_out_of_fence_heading(lines, from)
|
|
120
|
+
in_fence = false
|
|
121
|
+
fence_marker = nil
|
|
122
|
+
lines.each_with_index do |line, i|
|
|
123
|
+
if (m = fence_open_close(line, in_fence, fence_marker))
|
|
124
|
+
in_fence = m[:in_fence]
|
|
125
|
+
fence_marker = m[:marker]
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
return i if i >= from && !in_fence && line.start_with?("## ")
|
|
129
|
+
end
|
|
130
|
+
lines.length
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# PURE. Given the current fence state, decide whether `line` is a fence delimiter
|
|
134
|
+
# and return the new state, or nil when the line is not a fence delimiter.
|
|
135
|
+
# An opening fence records its marker family (` or ~); a closing fence must use a
|
|
136
|
+
# marker of the SAME family and carry no info string.
|
|
137
|
+
def fence_open_close(line, in_fence, fence_marker)
|
|
138
|
+
m = line.match(FENCE_RE)
|
|
139
|
+
return nil unless m
|
|
140
|
+
|
|
141
|
+
marker = m[1]
|
|
142
|
+
family = marker[0] # "`" or "~"
|
|
143
|
+
if in_fence
|
|
144
|
+
# A closing fence uses the same family, length >= the opener, no info string.
|
|
145
|
+
rest = line.sub(FENCE_RE, "").strip
|
|
146
|
+
if family == fence_marker && rest.empty?
|
|
147
|
+
{ in_fence: false, marker: nil }
|
|
148
|
+
else
|
|
149
|
+
# A delimiter of the OTHER family (or an info-string line) inside a fence is
|
|
150
|
+
# literal content, not a fence event.
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
else
|
|
154
|
+
{ in_fence: true, marker: family }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# True iff the body has a REAL (out-of-fence) `## Links` heading. Used by callers
|
|
159
|
+
# to classify regenerate-vs-add without re-deriving fence state.
|
|
160
|
+
def links_heading?(body)
|
|
161
|
+
!real_links_heading_indices(body.to_s.lines).empty?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Replace the REAL `## Links` section (the [start, stop] line bounds) with
|
|
165
|
+
# `section_text`, preserving everything before the heading and after the section
|
|
166
|
+
# byte-for-byte (including any fenced example that lives BEFORE the real section).
|
|
167
|
+
def replace_section(body, section_text, bounds)
|
|
168
|
+
lines = body.lines
|
|
169
|
+
start, stop = bounds
|
|
170
|
+
before = lines[0...start].join
|
|
171
|
+
tail = (lines[stop..] || [])
|
|
172
|
+
|
|
173
|
+
# `section_text` already ends with exactly one newline. When there is trailing
|
|
174
|
+
# content (another section follows), separate the block from it with one blank
|
|
175
|
+
# line; otherwise the section ends the body.
|
|
176
|
+
block = tail.empty? ? section_text : "#{section_text}\n"
|
|
177
|
+
"#{before}#{block}#{tail.join}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Append a `## Links` section at end-of-body, after the last existing section,
|
|
181
|
+
# separated by exactly one blank line, preserving the body's trailing newline.
|
|
182
|
+
def insert_section(body, section_text)
|
|
183
|
+
trimmed = body.to_s.sub(/\s+\z/, "")
|
|
184
|
+
if trimmed.empty?
|
|
185
|
+
# An empty body (no sections) just becomes the section.
|
|
186
|
+
section_text
|
|
187
|
+
else
|
|
188
|
+
"#{trimmed}\n\n#{section_text}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# PURE. Extract the REAL `## Links` section text (fence-aware), normalized to the
|
|
193
|
+
# canonical block shape the projection emits: heading line + entry lines + a
|
|
194
|
+
# single trailing newline. Returns "" when there is no real section. Shared by
|
|
195
|
+
# the IO shell's audit and the doctor drift check so all three agree on the
|
|
196
|
+
# location. Raises AmbiguousLinks when more than one real heading exists.
|
|
197
|
+
def extract_section(body)
|
|
198
|
+
bounds = links_bounds(body)
|
|
199
|
+
return "" if bounds.nil?
|
|
200
|
+
|
|
201
|
+
start, stop = bounds
|
|
202
|
+
lines = body.to_s.lines
|
|
203
|
+
section = lines[(start + 1)...stop].join.sub(/\n+\z/, "\n")
|
|
204
|
+
section = "" if section.strip.empty?
|
|
205
|
+
"#{HEADING}\n#{section}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "qmd_sync"
|
|
5
|
+
|
|
6
|
+
# PowerTools — detect-then-degrade harness for Plastic's optional power-tools
|
|
7
|
+
# (intent 66b). It owns deterministic detection of each tool and builds an
|
|
8
|
+
# obligation ("mandate") string for whichever tools are present, so the agent is
|
|
9
|
+
# obliged (not merely reminded) to use them: QMD for finding intents, Serena for
|
|
10
|
+
# code navigation.
|
|
11
|
+
#
|
|
12
|
+
# Strictly detect-then-degrade: a tool that is absent contributes nothing, and
|
|
13
|
+
# `mandate` returns nil when no tool is present. Nothing here installs anything.
|
|
14
|
+
#
|
|
15
|
+
# Pure and dependency-injected: every detection runs through an injected callable
|
|
16
|
+
# or keyword probe (PATH scan / `.serena` marker walk), so the whole module is
|
|
17
|
+
# unit-testable with no real binaries, no network, and no global/ENV state.
|
|
18
|
+
module PowerTools
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# True when QMD is present. Reuses QmdSync.detect (PATH probe), injectable.
|
|
22
|
+
def qmd?(detector: QmdSync.method(:detect))
|
|
23
|
+
!!detector.call
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# True when Serena is present: a `.serena` directory exists in cwd or any
|
|
27
|
+
# ancestor, OR `serena` is resolvable on PATH. Both probes are injectable so
|
|
28
|
+
# tests do not depend on the host having Serena installed.
|
|
29
|
+
def serena?(cwd:, path_probe: method(:which_serena), marker_finder: method(:serena_marker?))
|
|
30
|
+
return true if marker_finder.call(cwd)
|
|
31
|
+
!!path_probe.call
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True when `serena` is an executable on PATH. Mirrors QmdSync.which_qmd.
|
|
35
|
+
def which_serena
|
|
36
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
37
|
+
candidate = File.join(dir, "serena")
|
|
38
|
+
File.file?(candidate) && File.executable?(candidate)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Walk up from cwd to the filesystem root, returning true if any level holds a
|
|
43
|
+
# `.serena` directory.
|
|
44
|
+
def serena_marker?(cwd)
|
|
45
|
+
dir = File.expand_path(cwd)
|
|
46
|
+
loop do
|
|
47
|
+
return true if Dir.exist?(File.join(dir, ".serena"))
|
|
48
|
+
parent = File.dirname(dir)
|
|
49
|
+
break if parent == dir
|
|
50
|
+
dir = parent
|
|
51
|
+
end
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Obligation text for whichever tools are present, joined by newlines, or nil
|
|
56
|
+
# when none are. One MANDATORY line per present tool.
|
|
57
|
+
def mandate(cwd:, qmd_detector: QmdSync.method(:detect), serena_detector: nil)
|
|
58
|
+
lines = []
|
|
59
|
+
|
|
60
|
+
if qmd?(detector: qmd_detector)
|
|
61
|
+
lines << "MANDATORY: you MUST use QMD (`qmd search` / `qmd query` over the " \
|
|
62
|
+
"`plastic-*` collections) to check for an existing or related intent " \
|
|
63
|
+
"before treating this as new work; do not grep/Read the store first."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
serena_present = serena_detector ? !!serena_detector.call : serena?(cwd: cwd)
|
|
67
|
+
if serena_present
|
|
68
|
+
lines << "MANDATORY: you MUST use Serena's symbolic tools (find_symbol / " \
|
|
69
|
+
"get_symbols_overview / find_referencing_symbols) for code navigation " \
|
|
70
|
+
"before grep/Read."
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return nil if lines.empty?
|
|
74
|
+
lines.join("\n")
|
|
75
|
+
end
|
|
76
|
+
end
|