@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.
Files changed (50) hide show
  1. package/PLASTIC.md +21 -5
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-executor.md +10 -0
  4. package/agents/plastic-intent-curator.md +7 -5
  5. package/agents/plastic-planner.md +11 -1
  6. package/agents/plastic-spec-specialist.md +9 -1
  7. package/hooks/statusline +150 -41
  8. package/package.json +1 -1
  9. package/scripts/agent-report +142 -0
  10. package/scripts/dashboard.rb +5 -3
  11. package/scripts/doctor.rb +243 -0
  12. package/scripts/lib/bridge.rb +72 -24
  13. package/scripts/lib/frontmatter_writer.rb +130 -0
  14. package/scripts/lib/graph_rebuild.rb +328 -0
  15. package/scripts/lib/installer_core.rb +5 -0
  16. package/scripts/lib/intent_validator.rb +79 -0
  17. package/scripts/lib/links_projection.rb +160 -0
  18. package/scripts/lib/links_section.rb +207 -0
  19. package/scripts/lib/power_tools.rb +76 -0
  20. package/scripts/lib/qmd_hook.rb +38 -25
  21. package/scripts/lib/qmd_sync.rb +21 -0
  22. package/scripts/new-intent +172 -22
  23. package/scripts/project-links +287 -0
  24. package/scripts/qmd-sync +50 -3
  25. package/scripts/rebuild-graph +244 -0
  26. package/scripts/spawn-preamble +18 -1
  27. package/skills/auto/SKILL.md +13 -3
  28. package/skills/auto/evals/evals.json +48 -0
  29. package/skills/auto/references/agent-architecture.md +20 -0
  30. package/skills/auto/references/agent-report-contract.md +86 -0
  31. package/skills/brainstorming/SKILL.md +1 -0
  32. package/skills/brainstorming/evals/evals.json +22 -0
  33. package/skills/continuing/SKILL.md +8 -1
  34. package/skills/continuing/evals/evals.json +9 -0
  35. package/skills/creating-intent/SKILL.md +28 -8
  36. package/skills/creating-intent/evals/evals.json +72 -0
  37. package/skills/creating-intent/references/lifecycle.md +12 -4
  38. package/skills/dashboard/SKILL.md +5 -0
  39. package/skills/dashboard/evals/evals.json +22 -0
  40. package/skills/executing-plan/SKILL.md +2 -2
  41. package/skills/intent-curator/SKILL.md +3 -1
  42. package/skills/intent-curator/evals/evals.json +22 -0
  43. package/skills/linking-intents/SKILL.md +17 -6
  44. package/skills/linking-intents/evals/evals.json +22 -0
  45. package/skills/linking-intents/references/zettelkasten.md +15 -3
  46. package/skills/managing-index/SKILL.md +6 -0
  47. package/skills/managing-index/evals/evals.json +22 -0
  48. package/skills/managing-index/references/zettelkasten-linking.md +7 -2
  49. package/skills/research/SKILL.md +8 -0
  50. 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
@@ -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 qmd-first UserPromptSubmit hook (intent 66).
7
- # Pure and dependency-injected: returns the additionalContext string to emit, or
8
- # nil to emit nothing. The executable hook wires real deps and prints; this is
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
- return nil unless detector.call
22
- p = prompt.to_s.strip
23
- return nil if p.length < MIN_PROMPT_LENGTH
24
- return nil if p.downcase == "continue"
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
- collections = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
27
- hits = QmdSync.search(p, collections: collections, limit: limit,
28
- min_score: min_score, runner: runner, detector: detector)
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 hits.any?
32
- parts << "Related / prior Plastic intents (qmd BM25, includes completed) — " \
33
- "check before treating this as new work:"
34
- hits.each do |h|
35
- loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
36
- pct = (h[:score] * 100).round
37
- parts << "- [#{pct}%] #{loc} #{h[:title]}"
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
- parts << REMINDER
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
@@ -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))
@@ -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
- # Append a wikilink line under the file's `## Links` section, idempotently.
77
- def append_link(file_path, link_line)
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 if content.include?(link_line)
88
+ return unless content.start_with?("---")
81
89
 
82
- lines = content.lines
83
- idx = lines.index { |l| l.strip == "## Links" }
84
- return unless idx
90
+ parts = content.split("---", 3)
91
+ return unless parts.length >= 3
85
92
 
86
- insert_at = lines.length
87
- ((idx + 1)...lines.length).each do |j|
88
- if lines[j].strip.start_with?("## ")
89
- insert_at = j
90
- break
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
- lines.insert(insert_at, "#{link_line}\n")
94
- File.write(file_path, lines.join)
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
- # 4. Reciprocal links: forward link to parent + back-reference in the parent.
144
- if opts[:parent] && !opts[:parent].empty?
145
- parent_id = opts[:parent]
146
- append_link(intent_file, "- [[#{parent_id}]]")
147
- parent_dir = Dir.glob(File.join(store, "#{parent_id}--*")).find { |d| File.directory?(d) }
148
- if parent_dir
149
- parent_file = File.join(parent_dir, "#{File.basename(parent_dir)}.md")
150
- append_link(parent_file, "- [[#{id}]]")
151
- end
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.