@zalom/plastic 1.0.0-beta.8 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC-reference.md +133 -0
- package/PLASTIC.md +407 -84
- package/README.md +99 -82
- package/agents/plastic-brainstorming.md +12 -9
- package/agents/plastic-enforcer.md +52 -12
- package/agents/plastic-executor.md +10 -10
- package/agents/plastic-future-intent-researcher.md +4 -8
- package/agents/plastic-intent-curator.md +12 -14
- package/agents/plastic-intent-discovery.md +35 -0
- package/agents/plastic-planner.md +15 -10
- package/agents/plastic-spec-specialist.md +15 -10
- package/bin/plastic.js +7 -3
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +28 -3
- package/hooks/lock-gate +21 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +48 -6
- package/package.json +2 -1
- package/scripts/agent-report +21 -0
- package/scripts/dashboard.rb +98 -23
- package/scripts/doctor.rb +404 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-continue +17 -0
- package/scripts/hook-create-gate +45 -9
- package/scripts/hook-gate-check +24 -23
- package/scripts/hook-lock-gate +83 -0
- package/scripts/hook-retrieval-gate +148 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/install.rb +44 -7
- package/scripts/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +924 -84
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +104 -60
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +22 -21
- package/scripts/lib/preflight.rb +79 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +384 -0
- package/scripts/link-suggest +213 -0
- package/scripts/new-intent +138 -29
- package/scripts/plastic-lock +164 -0
- package/scripts/project-links +287 -0
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +11 -3
- package/scripts/update.rb +16 -7
- package/skills/auto/SKILL.md +126 -19
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/auto/references/end-tail.md +56 -0
- package/skills/auto/references/human-report-contract.md +55 -0
- package/skills/auto/references/tiers.md +77 -0
- package/skills/brainstorming/SKILL.md +7 -34
- package/skills/brainstorming/references/design-principles.md +49 -0
- package/skills/continuing/SKILL.md +26 -7
- package/skills/creating-intent/SKILL.md +13 -28
- package/skills/creating-intent/references/lifecycle.md +9 -4
- package/skills/creating-project/SKILL.md +11 -74
- package/skills/creating-project/references/project-scaffolding.md +97 -0
- package/skills/creating-skills/SKILL.md +65 -0
- package/skills/creating-skills/evals/evals.json +108 -0
- package/skills/creating-skills/references/agents.md +168 -0
- package/skills/creating-skills/references/evals.md +41 -0
- package/skills/creating-skills/references/hooks.md +248 -0
- package/skills/creating-skills/references/progressive-disclosure.md +176 -0
- package/skills/creating-skills/references/scripts.md +166 -0
- package/skills/creating-skills/references/skills.md +165 -0
- package/skills/creating-skills/scripts/scaffold.rb +313 -0
- package/skills/dashboard/SKILL.md +13 -11
- package/skills/dashboard/references/classification.md +22 -0
- package/skills/doctor/SKILL.md +10 -7
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/humanizer/SKILL.md +39 -0
- package/skills/humanizer/evals/evals.json +70 -0
- package/skills/humanizer/references/always-on-snippet.md +9 -0
- package/skills/humanizer/references/examples.md +48 -0
- package/skills/install/SKILL.md +75 -84
- package/skills/intent-curator/SKILL.md +5 -1
- package/skills/intent-discovery/SKILL.md +47 -0
- package/skills/intent-starting/SKILL.md +130 -0
- package/skills/intent-starting/evals/evals.json +117 -0
- package/skills/intent-starting/references/boarding-matrix.md +35 -0
- package/skills/linking-intents/SKILL.md +48 -12
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +44 -29
- package/skills/releasing/references/promotion-and-tagging.md +60 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roadmap/references/file-format.md +91 -0
- package/skills/roadmap/references/operations.md +78 -0
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
- package/skills/writing-plans/SKILL.md +10 -88
- package/skills/writing-plans/references/plan-format.md +102 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/templates/roadmap.md +30 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- package/skills/writing-instructions/references/agentskills-spec.md +0 -135
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# project-links — project each intent's corrected sources/chain graph into its
|
|
6
|
+
# canonical I5 `## Links` section, store-wide across the global, plastic, and
|
|
7
|
+
# knowdb stores (intent 72). Deterministic and idempotent: a second run over a
|
|
8
|
+
# projected store changes zero files. Touches ONLY the `## Links` section of each
|
|
9
|
+
# intent file; frontmatter and every other section stay byte-identical.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# project-links [--plastic-home PATH] [--dry-run] [--audit-path PATH]
|
|
13
|
+
#
|
|
14
|
+
# Pure-Ruby (no bash). The pure logic lives in lib/links_projection.rb and
|
|
15
|
+
# lib/links_section.rb; this shell does only discovery, IO, and reporting. It reads
|
|
16
|
+
# frontmatter (it NEVER writes frontmatter, that is intent 49's domain) and the
|
|
17
|
+
# on-disk `id--slug` directory basenames to build the cross-store resolver.
|
|
18
|
+
# Never pushes ~/.plastic (no git ops here).
|
|
19
|
+
|
|
20
|
+
require "time"
|
|
21
|
+
require "fileutils"
|
|
22
|
+
|
|
23
|
+
require_relative "lib/intent_validator"
|
|
24
|
+
require_relative "lib/graph_rebuild"
|
|
25
|
+
require_relative "lib/links_projection"
|
|
26
|
+
require_relative "lib/links_section"
|
|
27
|
+
|
|
28
|
+
class ProjectLinks
|
|
29
|
+
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
30
|
+
|
|
31
|
+
# The 72 intent dir audit destination (relative to plastic_home).
|
|
32
|
+
DEFAULT_AUDIT_REL =
|
|
33
|
+
"projects/plastic/store/72--links-graph-projection/resources/audit--links-projection.md"
|
|
34
|
+
|
|
35
|
+
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
|
|
36
|
+
@plastic_home = plastic_home
|
|
37
|
+
@dry_run = dry_run
|
|
38
|
+
|
|
39
|
+
# A dry run must NOT stomp the canonical audit (humans run --dry-run to review
|
|
40
|
+
# the plan). With no explicit --audit-path, a dry run writes a `.dry-run.md`
|
|
41
|
+
# sibling, leaving the canonical real-run audit untouched. An explicit
|
|
42
|
+
# --audit-path is always honored verbatim (tests inject it). Mirrors
|
|
43
|
+
# RebuildGraph's discipline exactly.
|
|
44
|
+
canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
|
|
45
|
+
@audit_path =
|
|
46
|
+
if audit_path
|
|
47
|
+
audit_path
|
|
48
|
+
elsif dry_run
|
|
49
|
+
canonical.sub(/\.md\z/, ".dry-run.md")
|
|
50
|
+
else
|
|
51
|
+
canonical
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_reader :plastic_home, :dry_run, :audit_path
|
|
56
|
+
|
|
57
|
+
# The three in-scope stores, each as { key:, root:, store:, index: }.
|
|
58
|
+
def stores
|
|
59
|
+
list = []
|
|
60
|
+
global_store = File.join(plastic_home, "store")
|
|
61
|
+
if File.directory?(global_store)
|
|
62
|
+
list << { key: "global", root: plastic_home, store: global_store,
|
|
63
|
+
index: File.join(plastic_home, "INDEX.md") }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
%w[plastic knowdb].each do |slug|
|
|
67
|
+
root = File.join(plastic_home, "projects", slug)
|
|
68
|
+
store = File.join(root, "store")
|
|
69
|
+
next unless File.directory?(store)
|
|
70
|
+
|
|
71
|
+
list << { key: "project:#{slug}", root: root, store: store,
|
|
72
|
+
index: File.join(root, "INDEX.md") }
|
|
73
|
+
end
|
|
74
|
+
list
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# { id => { sources:, chain:, basename:, label:, path: } } for one store.
|
|
78
|
+
def load_nodes(store_dir)
|
|
79
|
+
nodes = {}
|
|
80
|
+
Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
81
|
+
dir = File.join(store_dir, entry)
|
|
82
|
+
next unless File.directory?(dir)
|
|
83
|
+
|
|
84
|
+
md = File.join(dir, "#{entry}.md")
|
|
85
|
+
next unless File.exist?(md)
|
|
86
|
+
|
|
87
|
+
fm = IntentValidator.parse_frontmatter(md)
|
|
88
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
89
|
+
|
|
90
|
+
nodes[fm["id"].to_s] = {
|
|
91
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
92
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
93
|
+
basename: entry, # the on-disk `id--slug` directory name
|
|
94
|
+
label: fm["intent"].to_s.strip,
|
|
95
|
+
path: md,
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
nodes
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def run
|
|
102
|
+
store_list = stores
|
|
103
|
+
nodes_by_store = {}
|
|
104
|
+
index_texts = {}
|
|
105
|
+
store_index = {}
|
|
106
|
+
node_index = {}
|
|
107
|
+
|
|
108
|
+
store_list.each do |s|
|
|
109
|
+
key = s[:key]
|
|
110
|
+
nodes_by_store[key] = load_nodes(s[:store])
|
|
111
|
+
index_texts[key] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
|
|
112
|
+
store_index[key] = nodes_by_store[key].keys
|
|
113
|
+
node_index[key] = nodes_by_store[key].transform_values do |v|
|
|
114
|
+
{ basename: v[:basename], label: v[:label] }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
relocation_map = GraphRebuild.build_relocation_map(index_texts)
|
|
119
|
+
|
|
120
|
+
results = {}
|
|
121
|
+
store_list.each do |s|
|
|
122
|
+
key = s[:key]
|
|
123
|
+
results[key] = project_store(
|
|
124
|
+
key, nodes_by_store[key],
|
|
125
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
emit_audit(store_list, results)
|
|
130
|
+
results
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Project every intent in ONE store. Returns
|
|
134
|
+
# { entries: [ {id:, status:, before:, after:, error:} ], counts: {...} }.
|
|
135
|
+
def project_store(referer_store, nodes, relocation_map:, store_index:, node_index:)
|
|
136
|
+
entries = []
|
|
137
|
+
nodes.each do |id, node|
|
|
138
|
+
resolve = ->(ref) do
|
|
139
|
+
LinksProjection.resolve_ref_projection(
|
|
140
|
+
ref, referer_store: referer_store,
|
|
141
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
content = File.read(node[:path])
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
had_links = LinksSection.links_heading?(IntentValidator.body_of(content))
|
|
149
|
+
section_text = LinksProjection.section(
|
|
150
|
+
sources: node[:sources], chain: node[:chain], resolve: resolve
|
|
151
|
+
)
|
|
152
|
+
updated = LinksSection.rewrite(content, section_text)
|
|
153
|
+
rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
|
|
154
|
+
entries << { id: id, status: :failed, error: e.message }
|
|
155
|
+
next
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if updated == content
|
|
159
|
+
entries << { id: id, status: :unchanged }
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
status = had_links ? :regenerated : :added
|
|
164
|
+
File.write(node[:path], updated) unless dry_run
|
|
165
|
+
entries << { id: id, status: status,
|
|
166
|
+
before: extract_links(content), after: section_text }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
counts = entries.each_with_object(Hash.new(0)) { |e, h| h[e[:status]] += 1 }
|
|
170
|
+
{ entries: entries, counts: counts }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Pull the current REAL `## Links` section text (fence-aware) from a file's
|
|
174
|
+
# content, for the audit sample. Returns "" when absent. Delegates to the shared
|
|
175
|
+
# LinksSection.extract_section so the audit, the rewriter, and the doctor check
|
|
176
|
+
# all agree on the section location (and never match a heading inside an example
|
|
177
|
+
# code fence).
|
|
178
|
+
def extract_links(content)
|
|
179
|
+
LinksSection.extract_section(IntentValidator.body_of(content))
|
|
180
|
+
rescue LinksSection::AmbiguousLinks
|
|
181
|
+
"(ambiguous: multiple ## Links headings)"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Always write the audit (even in dry-run, so the human reviews the plan).
|
|
185
|
+
def emit_audit(store_list, results)
|
|
186
|
+
text = render_audit(store_list, results)
|
|
187
|
+
FileUtils.mkdir_p(File.dirname(audit_path))
|
|
188
|
+
File.write(audit_path, text)
|
|
189
|
+
text
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
STATUS_ORDER = %i[regenerated added unchanged failed].freeze
|
|
193
|
+
STATUS_LABELS = {
|
|
194
|
+
regenerated: "Regenerated (had a ## Links, content changed)",
|
|
195
|
+
added: "Added (no ## Links section, one inserted)",
|
|
196
|
+
unchanged: "Unchanged (already canonical)",
|
|
197
|
+
failed: "FAILED (resolver miss, NOT written)",
|
|
198
|
+
}.freeze
|
|
199
|
+
|
|
200
|
+
def render_audit(store_list, results)
|
|
201
|
+
lines = []
|
|
202
|
+
lines << "# Audit: store-wide ## Links projection (intent 72)"
|
|
203
|
+
lines << ""
|
|
204
|
+
lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
|
|
205
|
+
lines << ""
|
|
206
|
+
|
|
207
|
+
total = STATUS_ORDER.to_h do |st|
|
|
208
|
+
[st, store_list.sum { |s| results[s[:key]][:counts][st] }]
|
|
209
|
+
end
|
|
210
|
+
lines << "Totals across all stores: " \
|
|
211
|
+
"regenerated #{total[:regenerated]}, added #{total[:added]}, " \
|
|
212
|
+
"unchanged #{total[:unchanged]}, failed #{total[:failed]}."
|
|
213
|
+
lines << ""
|
|
214
|
+
|
|
215
|
+
store_list.each do |s|
|
|
216
|
+
key = s[:key]
|
|
217
|
+
res = results[key]
|
|
218
|
+
counts = res[:counts]
|
|
219
|
+
lines << "## #{key}"
|
|
220
|
+
lines << ""
|
|
221
|
+
lines << "Regenerated #{counts[:regenerated]}, added #{counts[:added]}, " \
|
|
222
|
+
"unchanged #{counts[:unchanged]}, failed #{counts[:failed]}."
|
|
223
|
+
lines << ""
|
|
224
|
+
|
|
225
|
+
failed = res[:entries].select { |e| e[:status] == :failed }
|
|
226
|
+
unless failed.empty?
|
|
227
|
+
lines << "### FAILED (#{failed.size})"
|
|
228
|
+
failed.each { |e| lines << "- #{e[:id]}: #{e[:error]}" }
|
|
229
|
+
lines << ""
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
sample = res[:entries].select { |e| %i[regenerated added].include?(e[:status]) }.first(5)
|
|
233
|
+
next if sample.empty?
|
|
234
|
+
|
|
235
|
+
lines << "### Sample before/after (first #{sample.size})"
|
|
236
|
+
sample.each do |e|
|
|
237
|
+
lines << "- #{e[:id]} (#{e[:status]}):"
|
|
238
|
+
lines << " - BEFORE:"
|
|
239
|
+
block_lines(e[:before]).each { |l| lines << " #{l}" }
|
|
240
|
+
lines << " - AFTER:"
|
|
241
|
+
block_lines(e[:after]).each { |l| lines << " #{l}" }
|
|
242
|
+
end
|
|
243
|
+
lines << ""
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
lines.join("\n") + "\n"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def block_lines(text)
|
|
250
|
+
s = text.to_s.strip
|
|
251
|
+
return ["(none)"] if s.empty?
|
|
252
|
+
|
|
253
|
+
s.lines.map(&:rstrip)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# True iff any intent failed (resolver miss).
|
|
257
|
+
def any_failed?(results)
|
|
258
|
+
results.values.any? { |r| r[:counts][:failed].to_i.positive? }
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if $PROGRAM_NAME == __FILE__
|
|
263
|
+
home = ProjectLinks::DEFAULT_HOME
|
|
264
|
+
dry = false
|
|
265
|
+
audit = nil
|
|
266
|
+
i = 0
|
|
267
|
+
while i < ARGV.length
|
|
268
|
+
case ARGV[i]
|
|
269
|
+
when "--plastic-home" then home = ARGV[i + 1]; i += 2
|
|
270
|
+
when "--dry-run" then dry = true; i += 1
|
|
271
|
+
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
272
|
+
else i += 1
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
tool = ProjectLinks.new(plastic_home: home, dry_run: dry, audit_path: audit)
|
|
277
|
+
results = tool.run
|
|
278
|
+
totals = ProjectLinks::STATUS_ORDER.to_h do |st|
|
|
279
|
+
[st, results.values.sum { |r| r[:counts][st] }]
|
|
280
|
+
end
|
|
281
|
+
puts "project-links #{dry ? "DRY RUN" : "applied"}: " \
|
|
282
|
+
"regenerated #{totals[:regenerated]}, added #{totals[:added]}, " \
|
|
283
|
+
"unchanged #{totals[:unchanged]}, failed #{totals[:failed]} " \
|
|
284
|
+
"across #{results.size} store(s)."
|
|
285
|
+
puts "Audit: #{tool.audit_path}"
|
|
286
|
+
exit 1 if tool.any_failed?(results)
|
|
287
|
+
end
|
package/scripts/read-config
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
require "yaml"
|
|
11
11
|
require "json"
|
|
12
|
+
require_relative "lib/agent_models"
|
|
12
13
|
|
|
13
14
|
DEFAULTS = {
|
|
14
15
|
"version" => 3,
|
|
@@ -23,6 +24,9 @@ DEFAULTS = {
|
|
|
23
24
|
"type" => "claude-code",
|
|
24
25
|
"parallel_mode" => "linear"
|
|
25
26
|
},
|
|
27
|
+
"agents" => {
|
|
28
|
+
"models" => AgentModels::TIER_DEFAULTS
|
|
29
|
+
},
|
|
26
30
|
"architect" => {
|
|
27
31
|
"style" => nil
|
|
28
32
|
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# rebuild-graph — repair the store-wide sources/chain frontmatter graph across the
|
|
6
|
+
# global, plastic, and knowdb stores (intent 49). Deterministic, idempotent, and
|
|
7
|
+
# one-directional (intent 68 I-invariants): dedupe, I3 (formative edge wins), I1
|
|
8
|
+
# in-store backlinks, I2 preserved; cross-store refs resolved via a multi-hop
|
|
9
|
+
# relocation map (relocation wins over coincidental id reuse). Emits a
|
|
10
|
+
# before/after audit, then writes minimal style-preserving frontmatter.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# rebuild-graph [--plastic-home PATH] [--dry-run] [--audit-path PATH]
|
|
14
|
+
#
|
|
15
|
+
# Pure-Ruby (no bash). The pure logic lives in lib/graph_rebuild.rb and
|
|
16
|
+
# lib/frontmatter_writer.rb; this shell does only discovery, IO, and reporting.
|
|
17
|
+
# Never pushes ~/.plastic (no git ops here).
|
|
18
|
+
|
|
19
|
+
require "yaml"
|
|
20
|
+
require "date"
|
|
21
|
+
require "time"
|
|
22
|
+
require "fileutils"
|
|
23
|
+
|
|
24
|
+
require_relative "lib/graph_rebuild"
|
|
25
|
+
require_relative "lib/frontmatter_writer"
|
|
26
|
+
require_relative "lib/intent_validator"
|
|
27
|
+
|
|
28
|
+
class RebuildGraph
|
|
29
|
+
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
30
|
+
|
|
31
|
+
# The 49 intent dir audit destination (relative to plastic_home).
|
|
32
|
+
DEFAULT_AUDIT_REL =
|
|
33
|
+
"projects/plastic/store/49--store-wide-double-link-symmetry/resources/audit--graph-rebuild.md"
|
|
34
|
+
|
|
35
|
+
KIND_LABELS = {
|
|
36
|
+
dedupe: "Dedupes",
|
|
37
|
+
i3: "I3 resolutions (kept in sources, dropped from chain)",
|
|
38
|
+
repoint: "Cross-store repoints",
|
|
39
|
+
collapse: "Cross-store collapses (to bare same-store id)",
|
|
40
|
+
drop: "Dropped dead refs",
|
|
41
|
+
i1_backlink: "I1 backlinks added",
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
KIND_ORDER = %i[dedupe i3 repoint collapse drop i1_backlink].freeze
|
|
45
|
+
|
|
46
|
+
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
|
|
47
|
+
@plastic_home = plastic_home
|
|
48
|
+
@dry_run = dry_run
|
|
49
|
+
|
|
50
|
+
# A dry run must NOT stomp the canonical audit (the spec/checklist tell humans
|
|
51
|
+
# to run --dry-run to review the plan). When no explicit --audit-path is given,
|
|
52
|
+
# a dry run writes to a distinct `.dry-run.md` sibling, leaving the canonical
|
|
53
|
+
# real-run audit untouched. An explicit --audit-path is always honored verbatim
|
|
54
|
+
# (it is the caller's responsibility, and tests inject it).
|
|
55
|
+
canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
|
|
56
|
+
@audit_path =
|
|
57
|
+
if audit_path
|
|
58
|
+
audit_path
|
|
59
|
+
elsif dry_run
|
|
60
|
+
canonical.sub(/\.md\z/, ".dry-run.md")
|
|
61
|
+
else
|
|
62
|
+
canonical
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_reader :plastic_home, :dry_run, :audit_path
|
|
67
|
+
|
|
68
|
+
# The three in-scope stores, each as { key:, root:, store:, index: }.
|
|
69
|
+
# `root` is the dir holding INDEX.md; `store` is the intents dir.
|
|
70
|
+
def stores
|
|
71
|
+
list = []
|
|
72
|
+
global_store = File.join(plastic_home, "store")
|
|
73
|
+
list << { key: "global", root: plastic_home, store: global_store,
|
|
74
|
+
index: File.join(plastic_home, "INDEX.md") } if File.directory?(global_store)
|
|
75
|
+
|
|
76
|
+
%w[plastic knowdb].each do |slug|
|
|
77
|
+
root = File.join(plastic_home, "projects", slug)
|
|
78
|
+
store = File.join(root, "store")
|
|
79
|
+
next unless File.directory?(store)
|
|
80
|
+
|
|
81
|
+
list << { key: "project:#{slug}", root: root, store: store,
|
|
82
|
+
index: File.join(root, "INDEX.md") }
|
|
83
|
+
end
|
|
84
|
+
list
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# { id => { sources:, chain:, path: } } for one store.
|
|
88
|
+
def load_nodes(store_dir)
|
|
89
|
+
nodes = {}
|
|
90
|
+
Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
|
|
91
|
+
dir = File.join(store_dir, entry)
|
|
92
|
+
next unless File.directory?(dir)
|
|
93
|
+
|
|
94
|
+
md = File.join(dir, "#{entry}.md")
|
|
95
|
+
next unless File.exist?(md)
|
|
96
|
+
|
|
97
|
+
fm = IntentValidator.parse_frontmatter(md)
|
|
98
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
99
|
+
|
|
100
|
+
nodes[fm["id"].to_s] = {
|
|
101
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
102
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
103
|
+
path: md,
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
nodes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run
|
|
110
|
+
store_list = stores
|
|
111
|
+
nodes_by_store = {}
|
|
112
|
+
index_texts = {}
|
|
113
|
+
store_index = {}
|
|
114
|
+
|
|
115
|
+
store_list.each do |s|
|
|
116
|
+
nodes_by_store[s[:key]] = load_nodes(s[:store])
|
|
117
|
+
index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
|
|
118
|
+
store_index[s[:key]] = nodes_by_store[s[:key]].keys
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
relocation_map = GraphRebuild.build_relocation_map(index_texts)
|
|
122
|
+
|
|
123
|
+
results = {}
|
|
124
|
+
store_list.each do |s|
|
|
125
|
+
key = s[:key]
|
|
126
|
+
input = nodes_by_store[key].transform_values { |v| { sources: v[:sources], chain: v[:chain] } }
|
|
127
|
+
results[key] = GraphRebuild.rebuild_store(
|
|
128
|
+
input,
|
|
129
|
+
referer_store: key,
|
|
130
|
+
relocation_map: relocation_map,
|
|
131
|
+
store_index: store_index
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
write_back(store_list, nodes_by_store, results) unless dry_run
|
|
136
|
+
emit_audit(store_list, nodes_by_store, results)
|
|
137
|
+
|
|
138
|
+
results
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Write changed frontmatter back via the minimal style-preserving writer.
|
|
142
|
+
def write_back(store_list, nodes_by_store, results)
|
|
143
|
+
store_list.each do |s|
|
|
144
|
+
key = s[:key]
|
|
145
|
+
new_nodes = results[key][:nodes]
|
|
146
|
+
nodes_by_store[key].each do |id, original|
|
|
147
|
+
rebuilt = new_nodes[id]
|
|
148
|
+
next if rebuilt.nil?
|
|
149
|
+
next if rebuilt[:sources] == original[:sources] && rebuilt[:chain] == original[:chain]
|
|
150
|
+
|
|
151
|
+
content = File.read(original[:path])
|
|
152
|
+
updated = FrontmatterWriter.rewrite_arrays(content,
|
|
153
|
+
sources: rebuilt[:sources],
|
|
154
|
+
chain: rebuilt[:chain])
|
|
155
|
+
File.write(original[:path], updated) if updated != content
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Render the audit and write it (always, even in dry-run, so the human reviews
|
|
161
|
+
# the dry-run plan). Returns the rendered string.
|
|
162
|
+
def emit_audit(store_list, _nodes_by_store, results)
|
|
163
|
+
text = render_audit(store_list, results)
|
|
164
|
+
FileUtils.mkdir_p(File.dirname(audit_path))
|
|
165
|
+
File.write(audit_path, text)
|
|
166
|
+
text
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# PURE-ish formatter (string from results). Per-store, grouped by kind.
|
|
170
|
+
def render_audit(store_list, results)
|
|
171
|
+
total = store_list.sum { |s| results[s[:key]][:changes].size }
|
|
172
|
+
lines = []
|
|
173
|
+
lines << "# Audit: store-wide sources/chain graph rebuild (intent 49)"
|
|
174
|
+
lines << ""
|
|
175
|
+
lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
|
|
176
|
+
lines << ""
|
|
177
|
+
lines << "Total changes across all stores: #{total}"
|
|
178
|
+
lines << ""
|
|
179
|
+
|
|
180
|
+
store_list.each do |s|
|
|
181
|
+
key = s[:key]
|
|
182
|
+
changes = results[key][:changes]
|
|
183
|
+
lines << "## #{key}"
|
|
184
|
+
lines << ""
|
|
185
|
+
if changes.empty?
|
|
186
|
+
lines << "No changes."
|
|
187
|
+
lines << ""
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
KIND_ORDER.each do |kind|
|
|
192
|
+
group = changes.select { |c| c[:kind] == kind }
|
|
193
|
+
next if group.empty?
|
|
194
|
+
|
|
195
|
+
lines << "### #{KIND_LABELS[kind]} (#{group.size})"
|
|
196
|
+
group.each { |c| lines << "- #{format_change(c)}" }
|
|
197
|
+
lines << ""
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
lines.join("\n") + "\n"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_change(c)
|
|
205
|
+
case c[:kind]
|
|
206
|
+
when :dedupe
|
|
207
|
+
"#{c[:intent]}: sources #{c[:before][:sources].inspect} → #{c[:after][:sources].inspect}, " \
|
|
208
|
+
"chain #{c[:before][:chain].inspect} → #{c[:after][:chain].inspect}"
|
|
209
|
+
when :i3
|
|
210
|
+
"#{c[:intent]}: #{c[:before]} kept in sources, dropped from chain"
|
|
211
|
+
when :repoint
|
|
212
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (relocated cross-store)"
|
|
213
|
+
when :collapse
|
|
214
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (collapsed to bare same-store id)"
|
|
215
|
+
when :drop
|
|
216
|
+
"#{c[:intent]}.#{c[:field]}: #{c[:before]} dropped (resolves nowhere)"
|
|
217
|
+
when :i1_backlink
|
|
218
|
+
"#{c[:intent]}.chain += #{c[:backlink]} (formative backlink)"
|
|
219
|
+
else
|
|
220
|
+
c.inspect
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if $PROGRAM_NAME == __FILE__
|
|
226
|
+
home = RebuildGraph::DEFAULT_HOME
|
|
227
|
+
dry = false
|
|
228
|
+
audit = nil
|
|
229
|
+
i = 0
|
|
230
|
+
while i < ARGV.length
|
|
231
|
+
case ARGV[i]
|
|
232
|
+
when "--plastic-home" then home = ARGV[i + 1]; i += 2
|
|
233
|
+
when "--dry-run" then dry = true; i += 1
|
|
234
|
+
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
235
|
+
else i += 1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
tool = RebuildGraph.new(plastic_home: home, dry_run: dry, audit_path: audit)
|
|
240
|
+
results = tool.run
|
|
241
|
+
total = results.values.sum { |r| r[:changes].size }
|
|
242
|
+
puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
|
|
243
|
+
puts "Audit: #{tool.audit_path}"
|
|
244
|
+
end
|
package/scripts/spawn-preamble
CHANGED
|
@@ -41,10 +41,18 @@ REPORT_CONTRACT =
|
|
|
41
41
|
"FINAL MESSAGE (your return value), not a side-channel file. Do not go idle or " \
|
|
42
42
|
"finish silently. The report carries a common envelope: role, intent id, stage, " \
|
|
43
43
|
"status (delivered or blocked), artifacts written, verification or tests run, " \
|
|
44
|
-
"checklist deltas, deviations from spec,
|
|
44
|
+
"checklist deltas, deviations from spec, blockers or handoff notes, and an " \
|
|
45
|
+
"insights field carrying 0..N durable nuggets (what I discovered worth keeping, " \
|
|
46
|
+
"the most interesting residue of this turn; none if there were none); plus a " \
|
|
45
47
|
"role-specific payload that fulfils your place in the What, Why, How, Exec cycle " \
|
|
46
|
-
"(for example the planner explains the plan back to the orchestrator).
|
|
47
|
-
"
|
|
48
|
+
"(for example the planner explains the plan back to the orchestrator). Populate " \
|
|
49
|
+
"the insights field even when you cannot write the intent file yourself: a " \
|
|
50
|
+
"background or dispatched agent carries each nugget home in the report and the " \
|
|
51
|
+
"orchestrator persists it via scripts/insight-append, so an insight never " \
|
|
52
|
+
"depends on the discovering session's file-write access. Keep the report " \
|
|
53
|
+
"prose-stripped: the envelope and payload only, no greeting, no preamble, " \
|
|
54
|
+
"no end-recap, no restating of the task; reasoning stays in the thinking channel. " \
|
|
55
|
+
"See skills/auto/references/agent-report-contract.md for the per-role format."
|
|
48
56
|
|
|
49
57
|
def parse_args(argv)
|
|
50
58
|
role = nil
|
package/scripts/update.rb
CHANGED
|
@@ -53,18 +53,22 @@ class Update < InstallerCore
|
|
|
53
53
|
end
|
|
54
54
|
puts "\u{2b06}\u{fe0f} Updating Plastic #{iv} \u{2192} #{res[:target]}"
|
|
55
55
|
exit_code = perform_switch(res[:target], agent_args(argv))
|
|
56
|
-
run_post_update_doctor if exit_code == 0
|
|
56
|
+
run_post_update_doctor(full: argv.include?("--full-doctor")) if exit_code == 0
|
|
57
57
|
exit_code
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
# Run
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
|
|
61
|
+
# Run doctor after a successful update and print a human-readable summary.
|
|
62
|
+
# Defaults to the fast core tier (agent registration + core files + manifest
|
|
63
|
+
# sync, binary pass|fail, no store walk) so a newcomer's first post-update
|
|
64
|
+
# run is not buried in convention warns they cannot act on. `full: true`
|
|
65
|
+
# (via `--full-doctor`) runs the complete store walk instead. Informational
|
|
66
|
+
# only: does not raise and does not affect the update's exit code. Accepts
|
|
67
|
+
# injected `doctor` and `out` for hermetic unit tests.
|
|
68
|
+
def run_post_update_doctor(doctor: nil, out: $stdout, full: false)
|
|
65
69
|
doctor ||= Doctor.new
|
|
66
|
-
out.puts "\nRunning full doctor after update..."
|
|
67
|
-
result = doctor.run_checks("claude")
|
|
70
|
+
out.puts full ? "\nRunning full doctor after update..." : "\nRunning core doctor after update..."
|
|
71
|
+
result = full ? doctor.run_checks("claude") : doctor.run_core_checks("claude")
|
|
68
72
|
s = result[:summary]
|
|
69
73
|
out.puts " Doctor status: #{result[:status]} " \
|
|
70
74
|
"(pass: #{s[:pass]}, warn: #{s[:warn]}, fail: #{s[:fail]}, total: #{s[:total]})"
|
|
@@ -152,6 +156,11 @@ class Update < InstallerCore
|
|
|
152
156
|
Agent options (default: --claude):
|
|
153
157
|
--claude --codex --hermes --all
|
|
154
158
|
|
|
159
|
+
Post-update doctor:
|
|
160
|
+
By default, a successful update runs the fast core doctor sync (agent
|
|
161
|
+
registration, core files, manifest — binary pass|fail, no store walk).
|
|
162
|
+
--full-doctor Run the full doctor (complete store walk) after updating.
|
|
163
|
+
|
|
155
164
|
Behaviour:
|
|
156
165
|
No flag advances to the next version on your current channel. Switching toward a
|
|
157
166
|
more stable channel is frictionless; switching toward bleeding edge is confirmed.
|