@zalom/plastic 1.0.0-beta.1 → 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 +21 -5
- package/agents/plastic-brainstorming.md +9 -1
- package/agents/plastic-executor.md +10 -0
- package/agents/plastic-intent-curator.md +7 -5
- package/agents/plastic-planner.md +11 -1
- package/agents/plastic-spec-specialist.md +9 -1
- package/hooks/statusline +150 -41
- package/package.json +1 -1
- package/scripts/agent-report +142 -0
- package/scripts/dashboard.rb +5 -3
- package/scripts/doctor.rb +243 -0
- package/scripts/lib/bridge.rb +72 -24
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +5 -0
- package/scripts/lib/intent_validator.rb +79 -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 +38 -25
- package/scripts/lib/qmd_sync.rb +21 -0
- package/scripts/new-intent +172 -22
- package/scripts/project-links +287 -0
- package/scripts/qmd-sync +50 -3
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +18 -1
- package/skills/auto/SKILL.md +13 -3
- package/skills/auto/evals/evals.json +48 -0
- package/skills/auto/references/agent-architecture.md +20 -0
- package/skills/auto/references/agent-report-contract.md +86 -0
- package/skills/brainstorming/SKILL.md +1 -0
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/continuing/SKILL.md +8 -1
- package/skills/continuing/evals/evals.json +9 -0
- package/skills/creating-intent/SKILL.md +28 -8
- package/skills/creating-intent/evals/evals.json +72 -0
- package/skills/creating-intent/references/lifecycle.md +12 -4
- package/skills/dashboard/SKILL.md +5 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/executing-plan/SKILL.md +2 -2
- package/skills/intent-curator/SKILL.md +3 -1
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +17 -6
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +15 -3
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +7 -2
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
|
@@ -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
|
package/scripts/lib/qmd_hook.rb
CHANGED
|
@@ -2,43 +2,56 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "qmd_sync"
|
|
5
|
+
require_relative "power_tools"
|
|
5
6
|
|
|
6
|
-
# QmdHook — decision logic for the
|
|
7
|
-
# Pure and dependency-injected: returns the additionalContext string to
|
|
8
|
-
# nil to emit nothing. The executable hook wires real deps and prints;
|
|
9
|
-
# unit-tested with a fake runner/detector (no real qmd, no network).
|
|
7
|
+
# QmdHook — decision logic for the power-tools UserPromptSubmit hook (intents 66,
|
|
8
|
+
# 66b). Pure and dependency-injected: returns the additionalContext string to
|
|
9
|
+
# emit, or nil to emit nothing. The executable hook wires real deps and prints;
|
|
10
|
+
# this is unit-tested with a fake runner/detector (no real qmd, no network).
|
|
11
|
+
#
|
|
12
|
+
# When qmd is present it still injects scored qmd hits (intent 66), then appends
|
|
13
|
+
# the PowerTools mandate (a MUST obligation per present tool: qmd for finding
|
|
14
|
+
# intents, serena for code navigation) instead of the old soft reminder.
|
|
10
15
|
module QmdHook
|
|
11
16
|
module_function
|
|
12
17
|
|
|
13
18
|
MIN_PROMPT_LENGTH = 10
|
|
14
|
-
REMINDER = "qmd is available: query it (`qmd search` / `qmd query` over the " \
|
|
15
|
-
"`plastic-*` collections) before grep/Read when gathering intent " \
|
|
16
|
-
"context (sources/chain) or checking whether this work already " \
|
|
17
|
-
"exists as an intent."
|
|
18
19
|
|
|
19
20
|
def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
|
|
20
|
-
detector: QmdSync.method(:detect), limit: 3, min_score: 0.5
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
detector: QmdSync.method(:detect), limit: 3, min_score: 0.5,
|
|
22
|
+
serena_detector: nil)
|
|
23
|
+
serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
|
|
24
|
+
qmd_present = !!detector.call
|
|
25
|
+
serena_present = !!serena_detector.call
|
|
26
|
+
return nil unless qmd_present || serena_present
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
p = prompt.to_s.strip
|
|
29
|
+
# The hit SEARCH is the only expensive step and the only one gated by prompt
|
|
30
|
+
# triviality: skip it for short or bare-"continue" prompts (and when qmd is
|
|
31
|
+
# absent). The mandate itself is always-on for whichever tools are present.
|
|
32
|
+
search_ok = qmd_present && p.length >= MIN_PROMPT_LENGTH && p.downcase != "continue"
|
|
29
33
|
|
|
30
34
|
parts = []
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
if search_ok
|
|
36
|
+
collections = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
|
|
37
|
+
hits = QmdSync.search(p, collections: collections, limit: limit,
|
|
38
|
+
min_score: min_score, runner: runner, detector: detector)
|
|
39
|
+
if hits.any?
|
|
40
|
+
parts << "Related / prior Plastic intents (qmd BM25, includes completed) — " \
|
|
41
|
+
"check before treating this as new work:"
|
|
42
|
+
hits.each do |h|
|
|
43
|
+
loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
|
|
44
|
+
pct = (h[:score] * 100).round
|
|
45
|
+
parts << "- [#{pct}%] #{loc} — #{h[:title]}"
|
|
46
|
+
end
|
|
47
|
+
parts << ""
|
|
38
48
|
end
|
|
39
|
-
parts << ""
|
|
40
49
|
end
|
|
41
|
-
|
|
50
|
+
|
|
51
|
+
mandate = PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
|
|
52
|
+
serena_detector: -> { serena_present })
|
|
53
|
+
parts << mandate if mandate
|
|
54
|
+
return nil if parts.empty?
|
|
42
55
|
parts.join("\n")
|
|
43
56
|
end
|
|
44
57
|
end
|
package/scripts/lib/qmd_sync.rb
CHANGED
|
@@ -99,6 +99,27 @@ module QmdSync
|
|
|
99
99
|
{ ran: true, ok: (ok1 && ok2) }
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
# Non-blocking reindex for the completion path. QMD has no incremental reindex
|
|
103
|
+
# (`qmd update` is a full rescan, `qmd embed -c` re-embeds the whole collection,
|
|
104
|
+
# ~2 min), so running it inline would block the agent's turn; spawning detached
|
|
105
|
+
# returns immediately. No-op when qmd absent. Spawner/detector are injected so
|
|
106
|
+
# tests assert behavior with no real qmd and no real spawned process.
|
|
107
|
+
def reindex_async(collection:, detector: method(:detect), spawner: method(:default_async_spawner))
|
|
108
|
+
return skip_result unless detector.call
|
|
109
|
+
pid = spawner.call(collection)
|
|
110
|
+
{ ran: true, async: true, pid: pid }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Default spawner: launch `qmd update && qmd embed -c <collection>` detached and
|
|
114
|
+
# non-blocking, discarding output, then detach so it never blocks the turn.
|
|
115
|
+
def default_async_spawner(collection)
|
|
116
|
+
require "shellwords"
|
|
117
|
+
cmd = "qmd update && qmd embed -c #{Shellwords.escape(collection)}"
|
|
118
|
+
pid = Process.spawn(cmd, out: File::NULL, err: File::NULL, pgroup: true)
|
|
119
|
+
Process.detach(pid)
|
|
120
|
+
pid
|
|
121
|
+
end
|
|
122
|
+
|
|
102
123
|
# Read-only status used by doctor and the session-start report line.
|
|
103
124
|
# Returns a structured hash; never mutates the index.
|
|
104
125
|
def status(plastic_home:, runner: default_runner, detector: method(:detect))
|
package/scripts/new-intent
CHANGED
|
@@ -26,6 +26,9 @@ require "fileutils"
|
|
|
26
26
|
require "date"
|
|
27
27
|
require_relative "lib/bridge"
|
|
28
28
|
require_relative "lib/intent_validator"
|
|
29
|
+
require_relative "lib/graph_rebuild"
|
|
30
|
+
require_relative "lib/links_projection"
|
|
31
|
+
require_relative "lib/links_section"
|
|
29
32
|
|
|
30
33
|
# --- Explicit flag parsing (no eval, no global injection) ------------------
|
|
31
34
|
|
|
@@ -73,25 +76,152 @@ def render_tokens(text, tokens)
|
|
|
73
76
|
tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}", v.to_s) }
|
|
74
77
|
end
|
|
75
78
|
|
|
76
|
-
#
|
|
77
|
-
|
|
79
|
+
# Add an id to a source intent's frontmatter `chain` array, idempotently (I1
|
|
80
|
+
# reciprocity: `child in parent.sources` => `parent.chain` gains `child`). A
|
|
81
|
+
# targeted edit of the `chain:` line only; the body (including `## Links`) is
|
|
82
|
+
# preserved byte-for-byte and no other frontmatter key is touched, so the file
|
|
83
|
+
# stays born-complete. Renders the array in flow style (`["a", "b"]`) to match
|
|
84
|
+
# templates/intent.md. No-op when the id is already present.
|
|
85
|
+
def add_to_chain(file_path, new_id)
|
|
78
86
|
return unless File.exist?(file_path)
|
|
79
87
|
content = File.read(file_path)
|
|
80
|
-
return
|
|
88
|
+
return unless content.start_with?("---")
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return unless idx
|
|
90
|
+
parts = content.split("---", 3)
|
|
91
|
+
return unless parts.length >= 3
|
|
85
92
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
fm = parts[1]
|
|
94
|
+
chain_line = fm.lines.find { |l| l.match?(/\A\s*chain\s*:/) }
|
|
95
|
+
return unless chain_line
|
|
96
|
+
|
|
97
|
+
existing = fm.match(/\bchain\s*:\s*\[(.*?)\]/m)
|
|
98
|
+
ids =
|
|
99
|
+
if existing
|
|
100
|
+
existing[1].scan(/"([^"]*)"|'([^']*)'/).flatten.compact
|
|
101
|
+
else
|
|
102
|
+
[]
|
|
91
103
|
end
|
|
104
|
+
return if ids.include?(new_id)
|
|
105
|
+
|
|
106
|
+
ids << new_id
|
|
107
|
+
rendered = "chain: [#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
|
|
108
|
+
new_fm = fm.sub(/^\s*chain\s*:.*$/, rendered)
|
|
109
|
+
File.write(file_path, ["", new_fm, parts[2]].join("---"))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Derive [plastic_home, referer_store_key] from a store directory path so the
|
|
113
|
+
# cross-store resolver (shared with project-links and the doctor check) can be
|
|
114
|
+
# built. A global store is `<home>/store` (key "global"); a project store is
|
|
115
|
+
# `<home>/projects/<slug>/store` (key "project:<slug>"). Returns
|
|
116
|
+
# [nil, "global"] only if the layout is unrecognized, in which case Links
|
|
117
|
+
# projection falls back to single-store resolution rooted at this store.
|
|
118
|
+
def store_context(store)
|
|
119
|
+
store = File.expand_path(store)
|
|
120
|
+
parent = File.dirname(store) # `<home>` or `<home>/projects/<slug>`
|
|
121
|
+
if File.basename(store) == "store" && File.basename(File.dirname(parent)) == "projects"
|
|
122
|
+
slug = File.basename(parent)
|
|
123
|
+
home = File.dirname(File.dirname(parent)) # strip projects/<slug>
|
|
124
|
+
[home, "project:#{slug}"]
|
|
125
|
+
elsif File.basename(store) == "store"
|
|
126
|
+
[parent, "global"] # `<home>/store`
|
|
127
|
+
else
|
|
128
|
+
[parent, "global"]
|
|
92
129
|
end
|
|
93
|
-
|
|
94
|
-
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The in-scope stores under `plastic_home`, each { key:, store: }. Mirrors
|
|
133
|
+
# project-links/RebuildGraph#stores so cross-store resolution spans the family.
|
|
134
|
+
def family_stores(plastic_home)
|
|
135
|
+
list = []
|
|
136
|
+
global_store = File.join(plastic_home, "store")
|
|
137
|
+
list << { key: "global", store: global_store } if File.directory?(global_store)
|
|
138
|
+
%w[plastic knowdb].each do |slug|
|
|
139
|
+
store = File.join(plastic_home, "projects", slug, "store")
|
|
140
|
+
list << { key: "project:#{slug}", store: store } if File.directory?(store)
|
|
141
|
+
end
|
|
142
|
+
list
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Build the cross-store maps the LinksProjection resolver needs:
|
|
146
|
+
# store_index => { store_key => [bare ids] }
|
|
147
|
+
# node_index => { store_key => { id => { basename:, label: } } }
|
|
148
|
+
# relocation_map => from GraphRebuild.build_relocation_map over every INDEX.md
|
|
149
|
+
# `fallback_store` is the store the new intent lives in; it is always included so
|
|
150
|
+
# resolution works even for a brand-new store with no INDEX.md yet.
|
|
151
|
+
def build_cross_store_maps(plastic_home, fallback_store_key, fallback_store_dir)
|
|
152
|
+
stores = family_stores(plastic_home)
|
|
153
|
+
# Ensure the fallback store is represented even if family discovery missed it.
|
|
154
|
+
unless stores.any? { |s| s[:key] == fallback_store_key }
|
|
155
|
+
stores << { key: fallback_store_key, store: fallback_store_dir }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
store_index = Hash.new { |h, k| h[k] = [] }
|
|
159
|
+
node_index = Hash.new { |h, k| h[k] = {} }
|
|
160
|
+
index_texts = {}
|
|
161
|
+
|
|
162
|
+
stores.each do |s|
|
|
163
|
+
next unless File.directory?(s[:store])
|
|
164
|
+
|
|
165
|
+
Dir.children(s[:store]).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
166
|
+
dir = File.join(s[:store], entry)
|
|
167
|
+
next unless File.directory?(dir)
|
|
168
|
+
|
|
169
|
+
md = File.join(dir, "#{entry}.md")
|
|
170
|
+
next unless File.exist?(md)
|
|
171
|
+
|
|
172
|
+
fm = IntentValidator.parse_frontmatter(md)
|
|
173
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
174
|
+
|
|
175
|
+
id = fm["id"].to_s
|
|
176
|
+
store_index[s[:key]] << id
|
|
177
|
+
node_index[s[:key]][id] = { basename: entry, label: fm["intent"].to_s.strip }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
idx = File.join(File.dirname(s[:store]), "INDEX.md")
|
|
181
|
+
index_texts[s[:key]] = File.read(idx) if File.exist?(idx)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
relocation_map = GraphRebuild.build_relocation_map(index_texts)
|
|
185
|
+
{ store_index: store_index, node_index: node_index, relocation_map: relocation_map }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Re-project ONE intent file's `## Links` as the canonical I5 projection of its
|
|
189
|
+
# OWN frontmatter sources+chain, using the shared cross-store resolver. Writes the
|
|
190
|
+
# file only if the section changed (idempotent). Born-canonical: a fresh root with
|
|
191
|
+
# no edges gets the empty-state comment; the fence-aware rewriter touches only the
|
|
192
|
+
# real `## Links` section. Returns true on success, false when the intent could
|
|
193
|
+
# not be read or a ref was unresolvable (left unwritten, never a guessed link).
|
|
194
|
+
def project_links_for(file_path, referer_store_key, maps)
|
|
195
|
+
return false unless File.exist?(file_path)
|
|
196
|
+
|
|
197
|
+
content = File.read(file_path)
|
|
198
|
+
fm = IntentValidator.parse_frontmatter_text(content)
|
|
199
|
+
return false unless fm.is_a?(Hash)
|
|
200
|
+
|
|
201
|
+
resolve = lambda do |ref|
|
|
202
|
+
LinksProjection.resolve_ref_projection(
|
|
203
|
+
ref,
|
|
204
|
+
referer_store: referer_store_key,
|
|
205
|
+
relocation_map: maps[:relocation_map],
|
|
206
|
+
store_index: maps[:store_index],
|
|
207
|
+
node_index: maps[:node_index]
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
begin
|
|
212
|
+
section_text = LinksProjection.section(
|
|
213
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
214
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
215
|
+
resolve: resolve
|
|
216
|
+
)
|
|
217
|
+
updated = LinksSection.rewrite(content, section_text)
|
|
218
|
+
rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
|
|
219
|
+
warn "new-intent: could not project ## Links for #{File.basename(file_path)}: #{e.message}"
|
|
220
|
+
return false
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
File.write(file_path, updated) if updated != content
|
|
224
|
+
true
|
|
95
225
|
end
|
|
96
226
|
|
|
97
227
|
def main(argv)
|
|
@@ -140,17 +270,37 @@ def main(argv)
|
|
|
140
270
|
intent_file = File.join(intent_dir, "#{id}--#{slug}.md")
|
|
141
271
|
File.write(intent_file, intent_body)
|
|
142
272
|
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
273
|
+
# 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
|
|
274
|
+
# `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
|
|
275
|
+
# `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
|
|
276
|
+
# folds in `--parent`). Collect the touched source files so their `## Links` can be
|
|
277
|
+
# re-projected once the chain edges are on disk.
|
|
278
|
+
source_files = []
|
|
279
|
+
sources.each do |src_id|
|
|
280
|
+
next if src_id.nil? || src_id.empty?
|
|
281
|
+
|
|
282
|
+
src_dir = Dir.glob(File.join(store, "#{src_id}--*")).find { |d| File.directory?(d) }
|
|
283
|
+
next unless src_dir
|
|
284
|
+
|
|
285
|
+
src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
|
|
286
|
+
add_to_chain(src_file, id)
|
|
287
|
+
source_files << src_file
|
|
152
288
|
end
|
|
153
289
|
|
|
290
|
+
# 4b. Canonical `## Links` projection (intent 72): born-canonical, drift PREVENTED
|
|
291
|
+
# at the source. Build the cross-store resolver maps AFTER the chain backlinks are
|
|
292
|
+
# written, then project the NEW intent's Links from its own frontmatter (sources at
|
|
293
|
+
# birth, chain empty) and RE-project each source/parent's Links (it just gained the
|
|
294
|
+
# new id in its chain). The fence-aware rewriter and the resolved-target dedup are
|
|
295
|
+
# the same ones project-links and the doctor check use, so a freshly created intent
|
|
296
|
+
# and its sources both PASS graph_links_projection. A no-source root gets the
|
|
297
|
+
# canonical empty-state comment.
|
|
298
|
+
plastic_home, referer_store_key = store_context(store)
|
|
299
|
+
maps = build_cross_store_maps(plastic_home, referer_store_key, store)
|
|
300
|
+
|
|
301
|
+
project_links_for(intent_file, referer_store_key, maps)
|
|
302
|
+
source_files.uniq.each { |sf| project_links_for(sf, referer_store_key, maps) }
|
|
303
|
+
|
|
154
304
|
# 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
|
|
155
305
|
# line; the rendered template body follows so the file is a usable starting
|
|
156
306
|
# point once an agent deletes the sentinel.
|