@zalom/plastic 1.0.0-beta.2 → 1.0.0-beta.21

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 (82) hide show
  1. package/PLASTIC.md +131 -7
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-enforcer.md +1 -1
  4. package/agents/plastic-executor.md +11 -1
  5. package/agents/plastic-intent-curator.md +7 -5
  6. package/agents/plastic-planner.md +11 -1
  7. package/agents/plastic-spec-specialist.md +9 -1
  8. package/hooks/hooks.json +20 -0
  9. package/hooks/retrieval-gate +10 -0
  10. package/hooks/savepoint-pre +10 -0
  11. package/hooks/statusline +150 -41
  12. package/package.json +1 -1
  13. package/scripts/agent-report +163 -0
  14. package/scripts/doctor.rb +172 -0
  15. package/scripts/hook-auto-arm +1 -1
  16. package/scripts/hook-bash-gate +2 -2
  17. package/scripts/hook-code-gate +11 -6
  18. package/scripts/hook-create-gate +2 -2
  19. package/scripts/hook-gate-check +14 -23
  20. package/scripts/hook-retrieval-gate +136 -0
  21. package/scripts/hook-savepoint-pre +32 -0
  22. package/scripts/hook-session-start +1 -1
  23. package/scripts/insight-append +51 -0
  24. package/scripts/lib/bridge.rb +374 -34
  25. package/scripts/lib/frontmatter_writer.rb +130 -0
  26. package/scripts/lib/graph_rebuild.rb +328 -0
  27. package/scripts/lib/insights.rb +86 -0
  28. package/scripts/lib/installer_core.rb +23 -0
  29. package/scripts/lib/link_suggestions.rb +322 -0
  30. package/scripts/lib/links_projection.rb +160 -0
  31. package/scripts/lib/links_section.rb +207 -0
  32. package/scripts/lib/power_tools.rb +76 -0
  33. package/scripts/lib/qmd_hook.rb +38 -25
  34. package/scripts/lib/qmd_sync.rb +36 -0
  35. package/scripts/lib/retrieval_gate.rb +211 -0
  36. package/scripts/lib/worktree.rb +409 -0
  37. package/scripts/link-suggest +211 -0
  38. package/scripts/new-intent +138 -29
  39. package/scripts/project-links +287 -0
  40. package/scripts/qmd-sync +50 -3
  41. package/scripts/rebuild-graph +244 -0
  42. package/scripts/spawn-preamble +26 -1
  43. package/skills/auto/SKILL.md +58 -11
  44. package/skills/auto/evals/evals.json +48 -0
  45. package/skills/auto/references/agent-architecture.md +27 -4
  46. package/skills/auto/references/agent-report-contract.md +121 -0
  47. package/skills/brainstorming/SKILL.md +1 -0
  48. package/skills/brainstorming/evals/evals.json +22 -0
  49. package/skills/continuing/SKILL.md +30 -8
  50. package/skills/continuing/evals/evals.json +9 -0
  51. package/skills/creating-intent/SKILL.md +16 -2
  52. package/skills/creating-intent/evals/evals.json +16 -0
  53. package/skills/creating-intent/references/lifecycle.md +9 -4
  54. package/skills/creating-skills/SKILL.md +65 -0
  55. package/skills/creating-skills/evals/evals.json +108 -0
  56. package/skills/creating-skills/references/agents.md +168 -0
  57. package/skills/creating-skills/references/evals.md +41 -0
  58. package/skills/creating-skills/references/hooks.md +248 -0
  59. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  60. package/skills/creating-skills/references/scripts.md +166 -0
  61. package/skills/creating-skills/references/skills.md +165 -0
  62. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  63. package/skills/dashboard/SKILL.md +5 -0
  64. package/skills/dashboard/evals/evals.json +22 -0
  65. package/skills/executing-plan/SKILL.md +2 -2
  66. package/skills/humanizer/SKILL.md +39 -0
  67. package/skills/humanizer/evals/evals.json +70 -0
  68. package/skills/humanizer/references/always-on-snippet.md +9 -0
  69. package/skills/humanizer/references/examples.md +48 -0
  70. package/skills/intent-curator/SKILL.md +6 -1
  71. package/skills/intent-curator/evals/evals.json +22 -0
  72. package/skills/linking-intents/SKILL.md +54 -12
  73. package/skills/linking-intents/evals/evals.json +22 -0
  74. package/skills/linking-intents/references/zettelkasten.md +7 -0
  75. package/skills/managing-index/SKILL.md +8 -0
  76. package/skills/managing-index/evals/evals.json +22 -0
  77. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  78. package/skills/releasing/SKILL.md +32 -0
  79. package/skills/research/SKILL.md +8 -0
  80. package/skills/research/evals/evals.json +22 -0
  81. package/skills/writing-instructions/SKILL.md +0 -159
  82. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -0,0 +1,130 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # FrontmatterWriter — pure, minimal, style-preserving rewrite of the `sources:`
5
+ # and `chain:` arrays in an intent file's content string (intent 49).
6
+ #
7
+ # It rewrites ONLY those two arrays and leaves every other frontmatter line and
8
+ # the entire body byte-identical. It preserves each array's existing serialization
9
+ # style independently:
10
+ # - flow style: `sources: ["40", "1a"]` (or `[]`)
11
+ # - block style: a `sources:` line followed by ` - '1a'` item lines
12
+ # When the desired array equals the file's current value (same ids, same order),
13
+ # the content is returned UNCHANGED so a re-run produces no diff (idempotency).
14
+ #
15
+ # Pure: no file IO, no eval, no global/ENV state. The IO shell reads/writes files.
16
+ module FrontmatterWriter
17
+ module_function
18
+
19
+ # Rewrite `sources:`/`chain:` in `content`. `sources`/`chain` are the desired
20
+ # final arrays of id strings. Returns the new content (or the original when
21
+ # nothing changed). Only operates within the leading `---`...`---` frontmatter
22
+ # block; never touches the body.
23
+ def rewrite_arrays(content, sources:, chain:)
24
+ return content unless content.is_a?(String) && content.start_with?("---")
25
+
26
+ parts = content.split("---", 3)
27
+ return content if parts.length < 3
28
+
29
+ fm = parts[1]
30
+ body = parts[2]
31
+
32
+ fm = rewrite_one(fm, "sources", sources)
33
+ fm = rewrite_one(fm, "chain", chain)
34
+
35
+ "---#{fm}---#{body}"
36
+ end
37
+
38
+ # Rewrite a single `key:` array within the frontmatter text `fm`, preserving the
39
+ # key's existing flow-vs-block style. No-op when the key is absent or unchanged.
40
+ def rewrite_one(fm, key, desired)
41
+ lines = fm.lines
42
+ idx = lines.index { |l| l.match?(/\A#{Regexp.escape(key)}:\s/) || l.match?(/\A#{Regexp.escape(key)}:\s*\z/) }
43
+ return fm if idx.nil?
44
+
45
+ header = lines[idx]
46
+ if block_style?(lines, idx)
47
+ rewrite_block(lines, idx, key, desired)
48
+ else
49
+ rewrite_flow(lines, idx, header, key, desired)
50
+ end
51
+ end
52
+
53
+ # The key is block style when its own line carries no inline value and the next
54
+ # non-blank line is a `-` list item.
55
+ def block_style?(lines, idx)
56
+ header = lines[idx]
57
+ inline = header.sub(/\A[^:]+:/, "").strip
58
+ return false unless inline.empty?
59
+
60
+ nxt = lines[idx + 1]
61
+ !nxt.nil? && nxt.match?(/\A\s*-\s/)
62
+ end
63
+
64
+ # Flow style: replace the inline array on the header line, preserving indentation
65
+ # and any trailing newline. No-op when the current ids already match `desired`.
66
+ def rewrite_flow(lines, idx, header, key, desired)
67
+ current = parse_flow(header)
68
+ return lines.join if current == desired
69
+
70
+ newline = header.end_with?("\n") ? "\n" : ""
71
+ lines[idx] = "#{key}: #{render_flow(desired)}#{newline}"
72
+ lines.join
73
+ end
74
+
75
+ # Parse the inline flow array from a `key: [ ... ]` header line.
76
+ def parse_flow(header)
77
+ inline = header.sub(/\A[^:]+:/, "").strip
78
+ return [] if inline.empty? || inline == "[]"
79
+
80
+ inline = inline.sub(/\A\[/, "").sub(/\]\z/, "")
81
+ inline.split(",").map { |t| t.strip.gsub(/\A['"]|['"]\z/, "") }.reject(&:empty?)
82
+ end
83
+
84
+ def render_flow(ids)
85
+ return "[]" if ids.empty?
86
+
87
+ "[#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
88
+ end
89
+
90
+ # Block style: replace the contiguous `-` item lines following the header. No-op
91
+ # when the current ids already match `desired`. Preserves the item indentation
92
+ # and quoting style sampled from the existing first item.
93
+ def rewrite_block(lines, idx, key, desired)
94
+ last = idx
95
+ item_lines = []
96
+ (idx + 1).upto(lines.length - 1) do |i|
97
+ break unless lines[i].match?(/\A\s*-\s/)
98
+
99
+ item_lines << lines[i]
100
+ last = i
101
+ end
102
+
103
+ current = item_lines.map { |l| l.sub(/\A\s*-\s*/, "").strip.gsub(/\A['"]|['"]\z/, "") }
104
+ return lines.join if current == desired
105
+
106
+ indent, quote = block_item_shape(item_lines.first)
107
+ rendered = desired.map { |id| "#{indent}- #{quote}#{id}#{quote}\n" }
108
+
109
+ # When desired is empty, collapse the block to an inline `key: []` to keep YAML
110
+ # valid (a bare `key:` with no items parses as nil, not an empty array).
111
+ rendered = ["#{key}: []\n"] if desired.empty? && rendered.empty?
112
+
113
+ if desired.empty?
114
+ new_lines = lines[0...idx] + rendered + lines[(last + 1)..]
115
+ else
116
+ new_lines = lines[0...idx] + [lines[idx]] + rendered + lines[(last + 1)..]
117
+ end
118
+ new_lines.join
119
+ end
120
+
121
+ # Sample indentation and quote char from an existing block item line.
122
+ def block_item_shape(sample)
123
+ return ["", "'"] if sample.nil?
124
+
125
+ indent = sample[/\A\s*/].to_s
126
+ value = sample.sub(/\A\s*-\s*/, "").strip
127
+ quote = value.start_with?('"') ? '"' : "'"
128
+ [indent, quote]
129
+ end
130
+ end
@@ -0,0 +1,328 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # GraphRebuild — pure logic for repairing the store-wide sources/chain graph
5
+ # (intent 49). Mirrors the pure-module style of IntentValidator: module-function
6
+ # helpers with no file IO. The IO shell (scripts/rebuild-graph) and doctor build
7
+ # the in-memory maps and feed them here.
8
+ #
9
+ # Two concerns live here:
10
+ # 1. Cross-store relocation. Each store's INDEX.md `## Relocated` log records
11
+ # moves like `global:24 → project:22c` (or backtick bare-id form `1b1a1 → 41`).
12
+ # build_relocation_map parses every log into a multi-hop-collapsed map; the
13
+ # resolver consults it BEFORE direct id resolution so a relocation always wins
14
+ # over a coincidentally-reused id (the `global:24` impostor hazard).
15
+ # 2. The per-intent rebuild transform (intent 68 I-invariants, one-directional):
16
+ # dedupe -> I3 (formative edge wins) -> cross-store resolve -> I1 backlinks ->
17
+ # I2 preserved (relational chains survive).
18
+ module GraphRebuild
19
+ module_function
20
+
21
+ RELOCATION_ARROW = "→" # the unicode → used in every Relocated log
22
+
23
+ # PURE. Parse every store's INDEX.md `## Relocated` block into a multi-hop
24
+ # relocation map.
25
+ #
26
+ # `index_texts` is { store_key => index_md_string } where store_key is "global"
27
+ # or "project:<slug>". Returns { [from_store, from_id] => [to_store, to_id] }
28
+ # with chains transitively collapsed to their final hop. The `to_store` token is
29
+ # NORMALIZED: the generic `project:` token in the global log is left as the
30
+ # literal it appears with; resolve_ref maps a same-family target to a bare id.
31
+ #
32
+ # Two real arrow forms are handled:
33
+ # - `global:24 → project:22c` (store-prefixed, global log)
34
+ # - `1b1a1 → 41` inside backticks (bare ids, plastic log) — these are
35
+ # same-store moves; from/to store both default to the store the log lives in.
36
+ def build_relocation_map(index_texts)
37
+ raw = {}
38
+ (index_texts || {}).each do |store_key, text|
39
+ next unless text.is_a?(String)
40
+
41
+ relocated_block(text).each_line do |line|
42
+ parse_relocation_line(line, store_key).each do |(from, to)|
43
+ raw[from] = to
44
+ end
45
+ end
46
+ end
47
+ collapse_multi_hop(raw)
48
+ end
49
+
50
+ # Extract the text of the `## Relocated` section (everything from the heading to
51
+ # the next top-level `## ` heading or EOF). Returns "" when absent.
52
+ def relocated_block(text)
53
+ lines = text.lines
54
+ start = lines.index { |l| l.strip == "## Relocated" }
55
+ return "" if start.nil?
56
+
57
+ rest = lines[(start + 1)..] || []
58
+ stop = rest.index { |l| l.start_with?("## ") }
59
+ (stop ? rest[0...stop] : rest).join
60
+ end
61
+
62
+ # Parse one log line into an array of [[from_store, from_id], [to_store, to_id]]
63
+ # pairs. A line may pack several comma-separated pairs and carry trailing prose
64
+ # in parens. Lines without an arrow yield []. `home_store` is the store whose
65
+ # log this line came from (used as the default store for bare ids).
66
+ def parse_relocation_line(line, home_store)
67
+ body = line.sub(/\A\s*-\s*/, "") # drop list bullet
68
+ return [] unless body.include?(RELOCATION_ARROW)
69
+
70
+ body.split(",").filter_map do |segment|
71
+ seg = segment.strip
72
+ next nil unless seg.include?(RELOCATION_ARROW)
73
+
74
+ left, right = seg.split(RELOCATION_ARROW, 2)
75
+ from = parse_token(left, home_store)
76
+ to = parse_token(right, home_store)
77
+ next nil if from.nil? || to.nil?
78
+
79
+ [from, to]
80
+ end
81
+ end
82
+
83
+ # Parse a single `store:id` / bare-id token (possibly wrapped in backticks or
84
+ # trailed by parenthetical prose) into [store_key, bare_id], or nil. A bare id
85
+ # defaults to `home_store`. The generic `project:` prefix is resolved later by
86
+ # resolve_ref against the referer's store family; here it is recorded literally.
87
+ def parse_token(token, home_store)
88
+ cleaned = token.to_s.tr("`", " ").strip
89
+ cleaned = cleaned.sub(/\s*\(.*\z/, "").strip # drop trailing "(prose"
90
+ cleaned = cleaned.split(/\s/).first.to_s # first whitespace-delimited atom
91
+ return nil if cleaned.empty?
92
+
93
+ if cleaned.include?(":")
94
+ store_tok, id = cleaned.split(":", 2)
95
+ return nil if id.to_s.empty?
96
+
97
+ [normalize_store_token(store_tok), id]
98
+ else
99
+ [home_store, cleaned]
100
+ end
101
+ end
102
+
103
+ # Normalize a store token from a log to a store_key. "global" stays "global".
104
+ # The generic "project" token (used in the global log) is kept as the sentinel
105
+ # "project" — resolve_ref binds it to the referer's project family.
106
+ def normalize_store_token(tok)
107
+ t = tok.to_s.strip
108
+ t == "global" ? "global" : t
109
+ end
110
+
111
+ # Transitively collapse a → b → c chains so every key maps to its FINAL hop.
112
+ # Cycle-guarded. Keys/values are [store_key, id] pairs.
113
+ def collapse_multi_hop(raw)
114
+ raw.each_with_object({}) do |(from, _to), acc|
115
+ seen = [from]
116
+ cur = raw[from]
117
+ while cur && raw.key?(cur) && !seen.include?(cur)
118
+ seen << cur
119
+ cur = raw[cur]
120
+ end
121
+ acc[from] = cur
122
+ end
123
+ end
124
+
125
+ # PURE. Resolve a single ref (a `store:id` cross-store ref, or a bare same-store
126
+ # id) to a final location and classification. ORDER IS LOAD-BEARING: the
127
+ # relocation map is consulted FIRST, so a relocation wins over a coincidentally
128
+ # reused id.
129
+ #
130
+ # ref — "global:24" or "22c"
131
+ # referer_store — store_key of the intent carrying the ref ("global"/"project:plastic")
132
+ # relocation_map — from build_relocation_map
133
+ # store_index — { store_key => Array/Set of bare ids present in that store }
134
+ #
135
+ # Returns a Hash:
136
+ # { status: :same_store, id: "<bare id>" } -> collapse to bare id
137
+ # { status: :cross_store, ref: "<store>:<id>" } -> keep/repoint store:id
138
+ # { status: :dead, ref: <original> } -> drop (resolves nowhere)
139
+ def resolve_ref(ref, referer_store:, relocation_map:, store_index:)
140
+ store_tok, bare = split_ref(ref, referer_store)
141
+
142
+ # 1) Relocation FIRST. Look up [store, id]; the generic "project" target token
143
+ # is bound to the referer's family when emitting the location.
144
+ reloc_key = [store_tok, bare]
145
+ if (relocation_map || {}).key?(reloc_key)
146
+ to_store, to_id = relocation_map[reloc_key]
147
+ to_store = bind_project_token(to_store, referer_store, store_index, to_id)
148
+ return classify(to_store, to_id, referer_store, store_index, ref)
149
+ end
150
+
151
+ # 2) Direct resolution against the live store index.
152
+ classify(store_tok, bare, referer_store, store_index, ref)
153
+ end
154
+
155
+ # Split a ref into [store_key, bare_id]; bare refs take the referer's store.
156
+ def split_ref(ref, referer_store)
157
+ s = ref.to_s
158
+ if s.include?(":")
159
+ store_tok, id = s.split(":", 2)
160
+ [normalize_store_token(store_tok), id]
161
+ else
162
+ [referer_store, s]
163
+ end
164
+ end
165
+
166
+ # The global log writes relocation targets as the generic `project:` token. Bind
167
+ # it to the concrete store family that actually owns the bare id. Prefer the
168
+ # referer's store when it holds the id; otherwise pick any store that has it.
169
+ def bind_project_token(to_store, referer_store, store_index, to_id)
170
+ return to_store unless to_store == "project"
171
+
172
+ return referer_store if ids_in(store_index, referer_store).include?(to_id)
173
+
174
+ owner = (store_index || {}).keys.find { |k| ids_in(store_index, k).include?(to_id) }
175
+ owner || referer_store
176
+ end
177
+
178
+ # Map a ref store token to the canonical store_index key. Refs in frontmatter
179
+ # use the slug form (`global`, `knowdb`, `plastic`); store_index keys use
180
+ # `global` and `project:<slug>`. "global" is canonical; anything else maps to
181
+ # `project:<token>` when that key exists, else the token itself (it may already
182
+ # be a `project:<slug>` key, e.g. a relocation target).
183
+ def canonical_store_key(store_tok, store_index)
184
+ return store_tok if store_tok == "global"
185
+ return store_tok if (store_index || {}).key?(store_tok)
186
+
187
+ projected = "project:#{store_tok}"
188
+ (store_index || {}).key?(projected) ? projected : store_tok
189
+ end
190
+
191
+ # The slug form of a canonical store key, for emitting a cross-store `slug:id`
192
+ # ref (the form used in frontmatter). "global" stays "global"; "project:<slug>"
193
+ # becomes "<slug>".
194
+ def slug_of(canonical_key)
195
+ canonical_key.to_s.sub(/\Aproject:/, "")
196
+ end
197
+
198
+ # Classify a resolved (store, id) relative to the referer, using the live store
199
+ # index to detect dead targets:
200
+ # - target id present in the referer's OWN store -> :same_store (collapse to bare)
201
+ # - target id present in a DIFFERENT store -> :cross_store (keep slug:id)
202
+ # - target id present NOWHERE -> :dead (drop)
203
+ def classify(store_tok, bare, referer_store, store_index, original_ref)
204
+ canonical = canonical_store_key(store_tok, store_index)
205
+ if ids_in(store_index, canonical).include?(bare)
206
+ if canonical == referer_store
207
+ { status: :same_store, id: bare }
208
+ else
209
+ { status: :cross_store, ref: "#{slug_of(canonical)}:#{bare}" }
210
+ end
211
+ else
212
+ { status: :dead, ref: original_ref.to_s }
213
+ end
214
+ end
215
+
216
+ def ids_in(store_index, store_key)
217
+ Array((store_index || {})[store_key])
218
+ end
219
+
220
+ # PURE. Per-store rebuild transform (intent 68 I-invariants, one-directional).
221
+ # Deterministic and idempotent: a second call over the result yields zero changes.
222
+ #
223
+ # nodes — ONE store's { id => { sources: [...], chain: [...] } } map
224
+ # referer_store — that store's key ("global" / "project:<slug>")
225
+ # relocation_map — from build_relocation_map (spans all stores)
226
+ # store_index — { store_key => bare ids present } (spans all stores)
227
+ #
228
+ # Returns { nodes: <new map>, changes: [ {intent:, kind:, before:, after:} ] }.
229
+ # kinds: :dedupe, :i3, :repoint, :collapse, :drop, :i1_backlink.
230
+ #
231
+ # Order is load-bearing (spec Phase 2):
232
+ # 1. dedupe each array order-preserving
233
+ # 2. I3: an id in BOTH sources and chain is kept in sources, dropped from chain
234
+ # 3. cross-store resolve each ref (relocation FIRST): repoint, collapse to bare
235
+ # same-store, or drop dead
236
+ # 4. I1: for every in-store source s, ensure s.chain backlinks this intent
237
+ # 5. I2 preserved: never synthesize a reciprocal source, never strip a
238
+ # relational chain entry
239
+ def rebuild_store(nodes, referer_store:, relocation_map:, store_index:)
240
+ out = {}
241
+ (nodes || {}).each do |id, edges|
242
+ edges = {} unless edges.is_a?(Hash)
243
+ out[id.to_s] = {
244
+ sources: Array(edges[:sources] || edges["sources"]).map(&:to_s),
245
+ chain: Array(edges[:chain] || edges["chain"]).map(&:to_s),
246
+ }
247
+ end
248
+
249
+ changes = []
250
+
251
+ out.each do |id, edges|
252
+ # 1. dedupe order-preserving
253
+ deduped_sources = edges[:sources].uniq
254
+ deduped_chain = edges[:chain].uniq
255
+ if deduped_sources != edges[:sources] || deduped_chain != edges[:chain]
256
+ changes << { intent: id, kind: :dedupe,
257
+ before: { sources: edges[:sources].dup, chain: edges[:chain].dup },
258
+ after: { sources: deduped_sources, chain: deduped_chain } }
259
+ end
260
+ edges[:sources] = deduped_sources
261
+ edges[:chain] = deduped_chain
262
+
263
+ # 2. I3: overlap kept in sources, dropped from chain
264
+ overlap = edges[:sources] & edges[:chain]
265
+ overlap.each do |o|
266
+ changes << { intent: id, kind: :i3, before: o, after: nil }
267
+ end
268
+ edges[:chain] -= overlap unless overlap.empty?
269
+
270
+ # 3. cross-store resolve sources and chain
271
+ %i[sources chain].each do |field|
272
+ rebuilt = []
273
+ edges[field].each do |ref|
274
+ unless ref.include?(":")
275
+ rebuilt << ref # bare same-store id, left as-is here (I4 is doctor's job)
276
+ next
277
+ end
278
+
279
+ res = resolve_ref(ref, referer_store: referer_store,
280
+ relocation_map: relocation_map, store_index: store_index)
281
+ case res[:status]
282
+ when :same_store
283
+ if res[:id] != ref
284
+ changes << { intent: id, kind: :collapse, field: field, before: ref, after: res[:id] }
285
+ end
286
+ rebuilt << res[:id]
287
+ when :cross_store
288
+ if res[:ref] != ref
289
+ changes << { intent: id, kind: :repoint, field: field, before: ref, after: res[:ref] }
290
+ end
291
+ rebuilt << res[:ref]
292
+ when :dead
293
+ changes << { intent: id, kind: :drop, field: field, before: ref, after: nil }
294
+ # dropped: not appended
295
+ end
296
+ end
297
+ # de-dupe again after collapse/repoint may have created duplicates
298
+ edges[field] = rebuilt.uniq
299
+ end
300
+
301
+ # 3b. Re-apply I3 AFTER resolution: a cross-store ref that collapses to a
302
+ # bare same-store id can newly overlap an existing chain entry (e.g.
303
+ # sources:[global:14a]→[19a] meeting chain:[19a]). Formative edge wins.
304
+ post_overlap = edges[:sources] & edges[:chain]
305
+ post_overlap.each do |o|
306
+ changes << { intent: id, kind: :i3, before: o, after: nil }
307
+ end
308
+ edges[:chain] -= post_overlap unless post_overlap.empty?
309
+ end
310
+
311
+ # 4. I1: in-store source backlinks (mutates OTHER nodes). Runs after resolution
312
+ # so collapsed bare ids participate. Order-preserving append.
313
+ out.each do |id, edges|
314
+ edges[:sources].each do |s|
315
+ next if s.include?(":") # cross-store: backlink lives in another store
316
+ next unless out.key?(s) # unresolved bare id is an I4 dangler, not I1
317
+ next if out[s][:chain].include?(id)
318
+
319
+ before_chain = out[s][:chain].dup
320
+ out[s][:chain] = out[s][:chain] + [id]
321
+ changes << { intent: s, kind: :i1_backlink, field: :chain,
322
+ before: before_chain, after: out[s][:chain].dup, backlink: id }
323
+ end
324
+ end
325
+
326
+ { nodes: out, changes: changes }
327
+ end
328
+ end
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ # insights.rb - the blessed write path for an intent's `## Insights` section
5
+ # (intent 82). A sibling library to bridge.rb so the insight write path stays
6
+ # independent of the savepoint ledger (the spec forbids touching the ledger).
7
+ #
8
+ # Every entry leads with a fixed, machine-parseable prefix
9
+ # `{utc-iso8601} · {stage} · {author}` where the timestamp is `now.utc.iso8601`,
10
+ # the SAME convention `Bridge.append_savepoint_line` uses, so the store has one
11
+ # timestamp convention across both ledgers. Entries are append-only, newest at
12
+ # the BOTTOM (the [[34]] ordering law): the per-entry prefix is not prepending
13
+ # the entry, and existing entries are never reordered.
14
+
15
+ require "time"
16
+ require_relative "bridge"
17
+
18
+ module Insights
19
+ HEADING = "## Insights"
20
+ # The middle dot (U+00B7) with a single space on each side separates the
21
+ # timestamp, stage, and author fields.
22
+ SEPARATOR = " · "
23
+ # A well-formed prefix: UTC ISO8601 timestamp to the second with a trailing
24
+ # `Z`, then a non-empty stage, then a non-empty author, each ` · `-separated.
25
+ PREFIX_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z · [^·]+ · [^·]+/.freeze
26
+
27
+ # Append one insight entry at the BOTTOM of the `## Insights` section of the
28
+ # intent file, creating the section (or the file) if absent. `now:` is the DI
29
+ # seam: defaults to Time.now, tests inject Time.utc(...). Raises ArgumentError
30
+ # if the assembled prefix is not well-formed (guard on write).
31
+ def self.append_insight(intent_dir, text, stage:, author:, now: Time.now)
32
+ prefix = "#{now.utc.iso8601}#{SEPARATOR}#{stage}#{SEPARATOR}#{author}"
33
+ unless valid_insight_prefix?(prefix)
34
+ raise ArgumentError, "malformed insight prefix: #{prefix.inspect}"
35
+ end
36
+
37
+ entry = "#{prefix} — #{text}"
38
+ path = Bridge.intent_file(intent_dir)
39
+ File.write(path, with_entry(File.exist?(path) ? File.read(path) : "", entry))
40
+ entry
41
+ end
42
+
43
+ # Pure validator: true when `line` begins with a well-formed prefix. No IO,
44
+ # no clock. Rejects a date-only prefix, a missing separator, a missing `Z`,
45
+ # and sub-second precision.
46
+ def self.valid_insight_prefix?(line)
47
+ line.is_a?(String) && line.match?(PREFIX_RE)
48
+ end
49
+
50
+ # Insert `entry` at the bottom of the `## Insights` section of `content`,
51
+ # preserving every existing line and their order. Pure string transform.
52
+ def self.with_entry(content, entry)
53
+ lines = content.empty? ? [] : content.split("\n", -1)
54
+ heading_idx = lines.index { |l| l.strip == HEADING }
55
+ return append_new_section(lines, entry) if heading_idx.nil?
56
+
57
+ insert_at = section_end(lines, heading_idx)
58
+ lines.insert(insert_at, entry)
59
+ lines.join("\n")
60
+ end
61
+
62
+ # The insertion index for a new entry: just after the last non-empty content
63
+ # line that belongs to the `## Insights` section (before the next `## `
64
+ # heading or EOF).
65
+ def self.section_end(lines, heading_idx)
66
+ last_content = heading_idx
67
+ idx = heading_idx + 1
68
+ while idx < lines.length
69
+ break if lines[idx].start_with?("## ")
70
+
71
+ last_content = idx unless lines[idx].strip.empty?
72
+ idx += 1
73
+ end
74
+ last_content + 1
75
+ end
76
+
77
+ # Append a fresh `## Insights` section (blank line, heading, entry) to the end
78
+ # of the existing content.
79
+ def self.append_new_section(lines, entry)
80
+ body = lines.join("\n")
81
+ body = body.sub(/\n+\z/, "") unless body.empty?
82
+ pieces = body.empty? ? [] : [body, ""]
83
+ pieces << HEADING << entry
84
+ "#{pieces.join("\n")}\n"
85
+ end
86
+ end
@@ -203,16 +203,30 @@ class InstallerCore
203
203
  "scripts/hook-continue" => "scripts/hook-continue",
204
204
  "scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
205
205
  "scripts/hook-gate-check" => "scripts/hook-gate-check",
206
+ "scripts/hook-savepoint-pre" => "scripts/hook-savepoint-pre",
206
207
  "scripts/hook-qmd-search" => "scripts/hook-qmd-search",
207
208
  "scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
209
+ "scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
208
210
  "scripts/hook-code-gate" => "scripts/hook-code-gate",
209
211
  "scripts/hook-bash-gate" => "scripts/hook-bash-gate",
212
+ "scripts/hook-retrieval-gate" => "scripts/hook-retrieval-gate",
213
+ "scripts/lib/retrieval_gate.rb" => "scripts/lib/retrieval_gate.rb",
210
214
  "scripts/hook-auto-arm" => "scripts/hook-auto-arm",
211
215
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
216
+ "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
217
+ "scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
212
218
  "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
213
219
  "scripts/lib/qmd_sync.rb" => "scripts/lib/qmd_sync.rb",
214
220
  "scripts/qmd-sync" => "scripts/qmd-sync",
215
221
  "scripts/lib/intent_validator.rb" => "scripts/lib/intent_validator.rb",
222
+ "scripts/lib/graph_rebuild.rb" => "scripts/lib/graph_rebuild.rb",
223
+ "scripts/lib/frontmatter_writer.rb" => "scripts/lib/frontmatter_writer.rb",
224
+ "scripts/lib/links_projection.rb" => "scripts/lib/links_projection.rb",
225
+ "scripts/lib/links_section.rb" => "scripts/lib/links_section.rb",
226
+ "scripts/lib/link_suggestions.rb" => "scripts/lib/link_suggestions.rb",
227
+ "scripts/project-links" => "scripts/project-links",
228
+ "scripts/link-suggest" => "scripts/link-suggest",
229
+ "scripts/rebuild-graph" => "scripts/rebuild-graph",
216
230
  "scripts/validate-intent" => "scripts/validate-intent",
217
231
  "scripts/new-intent" => "scripts/new-intent",
218
232
  "scripts/hook-create-gate" => "scripts/hook-create-gate",
@@ -556,6 +570,15 @@ class InstallerCore
556
570
  { "type" => "command", "command" => "#{hook_dir}/plastic-create-gate", "statusMessage" => "Checking create gate..." },
557
571
  ],
558
572
  },
573
+ # Retrieval gate (intent 84, Lever 2): redirects store-markdown reads to
574
+ # QMD and code reads to Serena when those tools are present. Binds the
575
+ # main agent AND subagents (PreToolUse applies to subagent tool calls).
576
+ {
577
+ "matcher" => "Bash|Read|Grep|Glob",
578
+ "hooks" => [
579
+ { "type" => "command", "command" => "#{hook_dir}/plastic-retrieval-gate", "statusMessage" => "Checking retrieval gate..." },
580
+ ],
581
+ },
559
582
  ],
560
583
  "PostToolUse" => {
561
584
  "matcher" => "Write|Edit",