@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +170 -469
- package/README.md +95 -58
- package/agents/plastic-brainstorming.md +45 -0
- package/agents/plastic-enforcer.md +36 -0
- package/agents/plastic-executor.md +47 -0
- package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
- package/agents/{intent-curator.md → plastic-intent-curator.md} +8 -6
- package/agents/plastic-planner.md +47 -0
- package/agents/plastic-spec-specialist.md +45 -0
- package/bin/plastic.js +57 -0
- package/bin/test +28 -0
- package/deprecations.yml +1 -10
- package/hooks/auto-arm +5 -0
- package/hooks/bash-gate +3 -0
- package/hooks/check-update +12 -8
- package/hooks/code-gate +12 -0
- package/hooks/create-gate +3 -0
- package/hooks/gate-check +3 -1
- package/hooks/hooks.json +52 -0
- package/hooks/qmd-search +8 -0
- package/hooks/statusline +150 -41
- package/package.json +2 -2
- package/scripts/agent-report +142 -0
- package/scripts/dashboard.rb +687 -0
- package/scripts/doctor.rb +1219 -621
- package/scripts/hook-auto-arm +51 -0
- package/scripts/hook-bash-gate +41 -0
- package/scripts/hook-code-gate +27 -0
- package/scripts/hook-continue +15 -114
- package/scripts/hook-create-gate +59 -0
- package/scripts/hook-gate-check +47 -32
- package/scripts/hook-qmd-search +44 -0
- package/scripts/hook-session-start +106 -38
- package/scripts/install.rb +91 -529
- package/scripts/lib/boot_banner.rb +28 -0
- package/scripts/lib/bridge.rb +452 -19
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +812 -0
- package/scripts/lib/intent_validator.rb +235 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/power_tools.rb +76 -0
- package/scripts/lib/qmd_hook.rb +57 -0
- package/scripts/lib/qmd_sync.rb +230 -0
- package/scripts/lib/store_provisioning.rb +100 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +327 -0
- package/scripts/project-links +287 -0
- package/scripts/provision-project-store +53 -0
- package/scripts/qmd-sync +139 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/select-update-target +93 -0
- package/scripts/spawn-preamble +138 -0
- package/scripts/uninstall.rb +53 -0
- package/scripts/update.rb +164 -0
- package/scripts/validate-intent +54 -0
- package/scripts/versions.rb +141 -0
- package/skills/_active-intent-gate.md +1 -1
- package/skills/add-project-store/SKILL.md +54 -0
- package/skills/auto/SKILL.md +87 -7
- package/skills/auto/evals/evals.json +255 -0
- package/skills/auto/references/agent-architecture.md +155 -0
- package/skills/auto/references/agent-report-contract.md +86 -0
- package/skills/brainstorming/SKILL.md +10 -9
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/brainstorming-grill-me/SKILL.md +6 -6
- package/skills/continuing/SKILL.md +99 -82
- package/skills/continuing/evals/evals.json +145 -0
- package/skills/continuing/references/context-management.md +32 -0
- package/skills/creating-intent/SKILL.md +82 -36
- package/skills/creating-intent/evals/evals.json +72 -0
- package/skills/creating-intent/references/lifecycle.md +81 -0
- package/skills/creating-intent/references/wikilinks.md +8 -0
- package/skills/creating-project/SKILL.md +40 -8
- package/skills/creating-project/references/hubs-projects.md +55 -0
- package/skills/dashboard/SKILL.md +126 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/dashboard/templates/dashboard-global.md +31 -0
- package/skills/dashboard/templates/dashboard-project.md +40 -0
- package/skills/doctor/SKILL.md +51 -4
- package/skills/doctor/references/gates-stuck-detection.md +38 -0
- package/skills/doctor/report.md +4 -0
- package/skills/evaluating-skills/SKILL.md +140 -0
- package/skills/evaluating-skills/assets/eval-template.json +12 -0
- package/skills/evaluating-skills/evals/evals.json +75 -0
- package/skills/evaluating-skills/references/convention-checks.md +76 -0
- package/skills/evaluating-skills/references/eval-methodology.md +154 -0
- package/skills/executing-plan/SKILL.md +5 -3
- package/skills/install/SKILL.md +69 -8
- package/skills/intent-curator/SKILL.md +6 -4
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +22 -7
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +45 -0
- package/skills/managing-index/SKILL.md +11 -1
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +7 -2
- package/skills/releasing/SKILL.md +80 -23
- package/skills/releasing/references/deprecations.md +60 -0
- package/skills/research/SKILL.md +10 -2
- package/skills/research/evals/evals.json +22 -0
- package/skills/savepoint/SKILL.md +46 -37
- package/skills/savepoint/references/context-management.md +32 -0
- package/skills/uninstall/SKILL.md +39 -28
- package/skills/update/SKILL.md +41 -44
- package/skills/versions/SKILL.md +65 -0
- package/skills/writing-instructions/SKILL.md +159 -0
- package/skills/writing-instructions/references/agentskills-spec.md +135 -0
- package/skills/writing-plans/SKILL.md +5 -5
- package/templates/agents.md +7 -7
- package/templates/outcome.md +13 -0
- package/templates/savepoint.md +14 -13
- package/templates/spec.md +25 -0
- package/bin/install.js +0 -29
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# new-intent (intent 60b) - the one-call scaffolding contract.
|
|
6
|
+
#
|
|
7
|
+
# A single invocation scaffolds a COMPLETE intent: it allocates the Folgezettel
|
|
8
|
+
# id (root vs branch by --parent), creates the directory tree (<id>--<slug>/ plus
|
|
9
|
+
# actions/ and resources/), renders the born-complete intent file from
|
|
10
|
+
# templates/intent.md, writes sentinel placeholder lifecycle files (spec.md,
|
|
11
|
+
# plan.md, checklist.md, outcome.md, each carrying <!-- plastic:placeholder -->
|
|
12
|
+
# as its first line), wires reciprocal file links, and self-validates with
|
|
13
|
+
# IntentValidator (exit non-zero if not born complete).
|
|
14
|
+
#
|
|
15
|
+
# It does NOT touch INDEX.md, git, or project creation: those remain skill/agent
|
|
16
|
+
# responsibilities (see plastic-creating-intent).
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# new-intent --store <store_path> --intent "<one-line>" --slug <slug> \
|
|
20
|
+
# [--parent <id>] [--author <name>] [--sources id,id] \
|
|
21
|
+
# [--tags tag,tag] [--templates <dir>]
|
|
22
|
+
#
|
|
23
|
+
# Exit codes: 0 (born complete), 1 (scaffold failed self-validation or bad input).
|
|
24
|
+
|
|
25
|
+
require "fileutils"
|
|
26
|
+
require "date"
|
|
27
|
+
require_relative "lib/bridge"
|
|
28
|
+
require_relative "lib/intent_validator"
|
|
29
|
+
require_relative "lib/graph_rebuild"
|
|
30
|
+
require_relative "lib/links_projection"
|
|
31
|
+
require_relative "lib/links_section"
|
|
32
|
+
|
|
33
|
+
# --- Explicit flag parsing (no eval, no global injection) ------------------
|
|
34
|
+
|
|
35
|
+
def parse_args(argv)
|
|
36
|
+
opts = {
|
|
37
|
+
store: nil, intent: nil, slug: nil, parent: nil,
|
|
38
|
+
author: "claude-code", sources: [], tags: [], templates: nil
|
|
39
|
+
}
|
|
40
|
+
i = 0
|
|
41
|
+
while i < argv.length
|
|
42
|
+
arg = argv[i]
|
|
43
|
+
case arg
|
|
44
|
+
when "--store" then opts[:store] = argv[i += 1]
|
|
45
|
+
when "--intent" then opts[:intent] = argv[i += 1]
|
|
46
|
+
when "--slug" then opts[:slug] = argv[i += 1]
|
|
47
|
+
when "--parent" then opts[:parent] = argv[i += 1]
|
|
48
|
+
when "--author" then opts[:author] = argv[i += 1]
|
|
49
|
+
when "--sources" then opts[:sources] = split_list(argv[i += 1])
|
|
50
|
+
when "--tags" then opts[:tags] = split_list(argv[i += 1])
|
|
51
|
+
when "--templates" then opts[:templates] = argv[i += 1]
|
|
52
|
+
else
|
|
53
|
+
abort "new-intent: unknown argument #{arg.inspect}"
|
|
54
|
+
end
|
|
55
|
+
i += 1
|
|
56
|
+
end
|
|
57
|
+
opts
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def split_list(value)
|
|
61
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def expand(path)
|
|
65
|
+
File.expand_path(path.to_s.sub(/\A~/, Dir.home))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Default templates dir: sibling of this script's dir. Works in-repo
|
|
69
|
+
# (<repo>/scripts/new-intent -> <repo>/templates) and installed
|
|
70
|
+
# (~/.plastic/scripts/new-intent -> ~/.plastic/templates when present).
|
|
71
|
+
def default_templates_dir
|
|
72
|
+
File.expand_path("../templates", __dir__)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def render_tokens(text, tokens)
|
|
76
|
+
tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}", v.to_s) }
|
|
77
|
+
end
|
|
78
|
+
|
|
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)
|
|
86
|
+
return unless File.exist?(file_path)
|
|
87
|
+
content = File.read(file_path)
|
|
88
|
+
return unless content.start_with?("---")
|
|
89
|
+
|
|
90
|
+
parts = content.split("---", 3)
|
|
91
|
+
return unless parts.length >= 3
|
|
92
|
+
|
|
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
|
+
[]
|
|
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"]
|
|
129
|
+
end
|
|
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
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def main(argv)
|
|
228
|
+
opts = parse_args(argv)
|
|
229
|
+
abort "new-intent: --store is required" if opts[:store].nil? || opts[:store].empty?
|
|
230
|
+
abort "new-intent: --intent is required" if opts[:intent].nil? || opts[:intent].empty?
|
|
231
|
+
abort "new-intent: --slug is required" if opts[:slug].nil? || opts[:slug].empty?
|
|
232
|
+
|
|
233
|
+
store = expand(opts[:store])
|
|
234
|
+
abort "new-intent: store dir does not exist: #{store}" unless Dir.exist?(store)
|
|
235
|
+
|
|
236
|
+
templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
|
|
237
|
+
abort "new-intent: templates dir not found: #{templates}" unless Dir.exist?(templates)
|
|
238
|
+
|
|
239
|
+
# 1. Allocate id via the existing folgezettel-id logic (root vs branch).
|
|
240
|
+
folg = File.expand_path("folgezettel-id", __dir__)
|
|
241
|
+
cmd = [folg, store]
|
|
242
|
+
cmd << opts[:parent] if opts[:parent] && !opts[:parent].empty?
|
|
243
|
+
id = `#{cmd.map { |c| "'#{c}'" }.join(" ")}`.strip
|
|
244
|
+
abort "new-intent: id allocation failed" if id.empty?
|
|
245
|
+
|
|
246
|
+
slug = opts[:slug]
|
|
247
|
+
intent_dir = File.join(store, "#{id}--#{slug}")
|
|
248
|
+
abort "new-intent: #{intent_dir} already exists" if File.exist?(intent_dir)
|
|
249
|
+
|
|
250
|
+
# 2. Create dirs.
|
|
251
|
+
FileUtils.mkdir_p(File.join(intent_dir, "actions"))
|
|
252
|
+
FileUtils.mkdir_p(File.join(intent_dir, "resources"))
|
|
253
|
+
|
|
254
|
+
# 3. Render the born-complete intent file from templates/intent.md.
|
|
255
|
+
sources = opts[:sources]
|
|
256
|
+
sources = sources | [opts[:parent]] if opts[:parent] && !opts[:parent].empty? && !sources.include?(opts[:parent])
|
|
257
|
+
sources_str = sources.map { |s| "\"#{s}\"" }.join(", ")
|
|
258
|
+
tags_str = opts[:tags].map { |t| "\"#{t}\"" }.join(", ")
|
|
259
|
+
|
|
260
|
+
intent_template = File.read(File.join(templates, "intent.md"))
|
|
261
|
+
intent_body = render_tokens(intent_template, {
|
|
262
|
+
"ID" => id,
|
|
263
|
+
"INTENT" => opts[:intent],
|
|
264
|
+
"SOURCES" => sources_str,
|
|
265
|
+
"DATE" => Date.today.iso8601,
|
|
266
|
+
"AUTHOR" => opts[:author],
|
|
267
|
+
"TAGS" => tags_str,
|
|
268
|
+
"DESCRIPTION" => opts[:intent],
|
|
269
|
+
})
|
|
270
|
+
intent_file = File.join(intent_dir, "#{id}--#{slug}.md")
|
|
271
|
+
File.write(intent_file, intent_body)
|
|
272
|
+
|
|
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
|
|
288
|
+
end
|
|
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
|
+
|
|
304
|
+
# 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
|
|
305
|
+
# line; the rendered template body follows so the file is a usable starting
|
|
306
|
+
# point once an agent deletes the sentinel.
|
|
307
|
+
%w[spec.md plan.md checklist.md outcome.md].each do |name|
|
|
308
|
+
template_path = File.join(templates, name)
|
|
309
|
+
body = File.exist?(template_path) ? File.read(template_path) : ""
|
|
310
|
+
body = render_tokens(body, { "INTENT_NAME" => opts[:intent] })
|
|
311
|
+
File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# 6. Self-validate (frontmatter + sanctioned sections).
|
|
315
|
+
result = IntentValidator.validate(intent_dir)
|
|
316
|
+
unless result[:ok]
|
|
317
|
+
warn "new-intent: scaffolded intent is NOT born complete:"
|
|
318
|
+
result[:missing].each { |f| warn " missing field: #{f}" }
|
|
319
|
+
result[:errors].each { |e| warn " #{e}" }
|
|
320
|
+
exit 1
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
puts intent_dir
|
|
324
|
+
exit 0
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
main(ARGV) if $PROGRAM_NAME == __FILE__
|
|
@@ -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
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# provision-project-store — deterministic CLI over StoreProvisioning (intent 61).
|
|
6
|
+
#
|
|
7
|
+
# Creates a registered project's store at ~/.plastic/projects/{slug}/store: it
|
|
8
|
+
# makes the store directory, then writes, only if missing, .gitkeep, INDEX.md,
|
|
9
|
+
# and project.yml. It is pure filesystem and idempotent, so re-running never
|
|
10
|
+
# clobbers existing files and performs no qmd mutation. The project must already
|
|
11
|
+
# be registered in projects.yml; an unregistered slug exits non-zero and creates
|
|
12
|
+
# nothing.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# provision-project-store <slug> [--home <path>]
|
|
16
|
+
#
|
|
17
|
+
# Exit codes: 0 (store provisioned), 1 (unregistered slug / error on stderr),
|
|
18
|
+
# 2 (usage). --home <path> overrides the Plastic home (default: ~/.plastic).
|
|
19
|
+
|
|
20
|
+
require_relative "lib/store_provisioning"
|
|
21
|
+
|
|
22
|
+
def plastic_home(args)
|
|
23
|
+
if (i = args.index("--home")) && args[i + 1]
|
|
24
|
+
File.expand_path(args[i + 1])
|
|
25
|
+
else
|
|
26
|
+
File.expand_path(ENV["PLASTIC_HOME"] || "~/.plastic")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
home = plastic_home(ARGV)
|
|
31
|
+
|
|
32
|
+
# First positional arg, skipping any flag and the value that follows --home.
|
|
33
|
+
home_value_index = (i = ARGV.index("--home")) ? i + 1 : nil
|
|
34
|
+
slug_index = ARGV.each_index.find do |idx|
|
|
35
|
+
arg = ARGV[idx]
|
|
36
|
+
!arg.start_with?("--") && idx != home_value_index
|
|
37
|
+
end
|
|
38
|
+
slug = slug_index && ARGV[slug_index]
|
|
39
|
+
|
|
40
|
+
if slug.nil?
|
|
41
|
+
warn "usage: provision-project-store <slug> [--home <path>]"
|
|
42
|
+
exit 2
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
result = StoreProvisioning.provision(slug, plastic_home: home)
|
|
46
|
+
|
|
47
|
+
if result[:ok]
|
|
48
|
+
puts "OK: #{result[:store_dir]}"
|
|
49
|
+
exit 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
warn result[:error]
|
|
53
|
+
exit 1
|