@zalom/plastic 1.0.0-beta.3 → 1.0.0-beta.30
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 +293 -7
- package/README.md +10 -0
- package/agents/plastic-brainstorming.md +2 -2
- package/agents/plastic-enforcer.md +17 -2
- package/agents/plastic-executor.md +3 -3
- package/agents/plastic-future-intent-researcher.md +2 -1
- package/agents/plastic-intent-curator.md +15 -7
- package/agents/plastic-intent-discovery.md +37 -0
- package/agents/plastic-planner.md +2 -2
- package/agents/plastic-spec-specialist.md +2 -2
- 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 +150 -41
- package/package.json +1 -1
- package/scripts/agent-report +21 -0
- 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-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/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +625 -47
- 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 +64 -54
- 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 +75 -0
- package/scripts/lib/qmd_hook.rb +38 -25
- package/scripts/lib/qmd_sync.rb +36 -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/qmd-sync +50 -3
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +11 -3
- package/skills/auto/SKILL.md +98 -16
- package/skills/auto/evals/evals.json +48 -0
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/brainstorming/SKILL.md +1 -0
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/continuing/SKILL.md +30 -8
- package/skills/continuing/evals/evals.json +9 -0
- package/skills/creating-intent/SKILL.md +16 -2
- package/skills/creating-intent/evals/evals.json +16 -0
- package/skills/creating-intent/references/lifecycle.md +9 -4
- 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 +5 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/doctor/SKILL.md +4 -1
- 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/intent-curator/SKILL.md +7 -1
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/intent-discovery/SKILL.md +46 -0
- package/skills/intent-starting/SKILL.md +127 -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 +54 -12
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +12 -0
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +32 -0
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- package/skills/writing-instructions/references/agentskills-spec.md +0 -135
package/scripts/new-intent
CHANGED
|
@@ -26,6 +26,9 @@ require "fileutils"
|
|
|
26
26
|
require "date"
|
|
27
27
|
require_relative "lib/bridge"
|
|
28
28
|
require_relative "lib/intent_validator"
|
|
29
|
+
require_relative "lib/graph_rebuild"
|
|
30
|
+
require_relative "lib/links_projection"
|
|
31
|
+
require_relative "lib/links_section"
|
|
29
32
|
|
|
30
33
|
# --- Explicit flag parsing (no eval, no global injection) ------------------
|
|
31
34
|
|
|
@@ -106,25 +109,119 @@ def add_to_chain(file_path, new_id)
|
|
|
106
109
|
File.write(file_path, ["", new_fm, parts[2]].join("---"))
|
|
107
110
|
end
|
|
108
111
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
114
144
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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)
|
|
118
168
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 }
|
|
124
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)
|
|
125
182
|
end
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
128
225
|
end
|
|
129
226
|
|
|
130
227
|
def main(argv)
|
|
@@ -176,8 +273,9 @@ def main(argv)
|
|
|
176
273
|
# 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
|
|
177
274
|
# `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
|
|
178
275
|
# `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
|
|
179
|
-
# folds in `--parent`).
|
|
180
|
-
#
|
|
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 = []
|
|
181
279
|
sources.each do |src_id|
|
|
182
280
|
next if src_id.nil? || src_id.empty?
|
|
183
281
|
|
|
@@ -186,19 +284,22 @@ def main(argv)
|
|
|
186
284
|
|
|
187
285
|
src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
|
|
188
286
|
add_to_chain(src_file, id)
|
|
287
|
+
source_files << src_file
|
|
189
288
|
end
|
|
190
289
|
|
|
191
|
-
# 4b.
|
|
192
|
-
# the
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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) }
|
|
202
303
|
|
|
203
304
|
# 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
|
|
204
305
|
# line; the rendered template body follows so the file is a usable starting
|
|
@@ -210,7 +311,15 @@ def main(argv)
|
|
|
210
311
|
File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
|
|
211
312
|
end
|
|
212
313
|
|
|
213
|
-
# 6.
|
|
314
|
+
# 6. Stamp the born savepoint line (intent 81). The first ledger line is the
|
|
315
|
+
# `What` bookend, written deterministically at creation rather than relying on
|
|
316
|
+
# a PostToolUse gate firing on the intent-file write (which is missed in some
|
|
317
|
+
# sessions / harnesses). The intent file is never a sentinel placeholder, so
|
|
318
|
+
# append_savepoint records `What {id}--{slug}.md`. Idempotent: a later gate
|
|
319
|
+
# fire adds nothing.
|
|
320
|
+
Bridge.append_savepoint(intent_dir, intent_file)
|
|
321
|
+
|
|
322
|
+
# 7. Self-validate (frontmatter + sanctioned sections).
|
|
214
323
|
result = IntentValidator.validate(intent_dir)
|
|
215
324
|
unless result[:ok]
|
|
216
325
|
warn "new-intent: scaffolded intent is NOT born complete:"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
#
|
|
5
|
+
# plastic-lock: inspect and repair the durable delivery lock (intent 108, D5),
|
|
6
|
+
# and take/free per-artifact claim tokens (intent 111 D1/D5).
|
|
7
|
+
#
|
|
8
|
+
# Usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim>
|
|
9
|
+
# [--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]
|
|
10
|
+
#
|
|
11
|
+
# Verbs:
|
|
12
|
+
# status report the lock file, the bridge cache, their agreement,
|
|
13
|
+
# and any live per-artifact claims
|
|
14
|
+
# fix idempotent repair: rebuild lock + bridge from disk truth for
|
|
15
|
+
# the current session; never touches a fresh foreign lock
|
|
16
|
+
# release owner clears the lock (End tail / abandoning a boarding)
|
|
17
|
+
# reclaim explicit takeover of a stale lock, audited in savepoint.md
|
|
18
|
+
# delegate owner registers a subagent session under the lock (D4)
|
|
19
|
+
# claim acquire the named --artifact's claim token (intent 111);
|
|
20
|
+
# rejected (exit 1) while a fresh claim is held by anyone,
|
|
21
|
+
# including this same session; takes over a stale claim
|
|
22
|
+
# release-claim free the named --artifact's claim token
|
|
23
|
+
#
|
|
24
|
+
# Without --intent-dir the intent is resolved from this session's bridge.
|
|
25
|
+
# Exit 0 on success/report; exit 1 when the verb is blocked (held elsewhere).
|
|
26
|
+
|
|
27
|
+
require "json"
|
|
28
|
+
require_relative "lib/bridge"
|
|
29
|
+
require_relative "lib/lock"
|
|
30
|
+
|
|
31
|
+
def usage!
|
|
32
|
+
warn "usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim> " \
|
|
33
|
+
"[--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]"
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
verb = ARGV.shift
|
|
38
|
+
usage! unless %w[status fix release reclaim delegate claim release-claim].include?(verb)
|
|
39
|
+
|
|
40
|
+
opts = {}
|
|
41
|
+
until ARGV.empty?
|
|
42
|
+
case (flag = ARGV.shift)
|
|
43
|
+
when "--intent-dir" then opts[:dir] = ARGV.shift
|
|
44
|
+
when "--session" then opts[:session] = ARGV.shift
|
|
45
|
+
when "--delegate" then opts[:delegate] = ARGV.shift
|
|
46
|
+
when "--artifact" then opts[:artifact] = ARGV.shift
|
|
47
|
+
else
|
|
48
|
+
warn "unknown flag #{flag}"
|
|
49
|
+
usage!
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
session = opts[:session]
|
|
54
|
+
session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.strip.empty?
|
|
55
|
+
|
|
56
|
+
dir = opts[:dir]
|
|
57
|
+
if dir.nil? || dir.strip.empty?
|
|
58
|
+
bridge = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
59
|
+
dir = Bridge.bridge_intent_dir(bridge)
|
|
60
|
+
end
|
|
61
|
+
if dir.nil?
|
|
62
|
+
warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
|
|
63
|
+
exit 1
|
|
64
|
+
end
|
|
65
|
+
dir = File.expand_path(dir)
|
|
66
|
+
|
|
67
|
+
intent_id = Bridge.intent_id_from_dir(dir)
|
|
68
|
+
store = File.dirname(dir)
|
|
69
|
+
name = File.basename(dir)
|
|
70
|
+
key = Bridge.resolve_session(session, intent_id: intent_id, store: store)
|
|
71
|
+
|
|
72
|
+
case verb
|
|
73
|
+
when "status"
|
|
74
|
+
lock = Lock.read(dir)
|
|
75
|
+
bridge = Bridge.read(key)
|
|
76
|
+
report = {
|
|
77
|
+
"intent_dir" => dir,
|
|
78
|
+
"session" => key,
|
|
79
|
+
"lock" => lock,
|
|
80
|
+
"lock_fresh" => Lock.fresh?(dir),
|
|
81
|
+
"lock_corrupt" => Lock.corrupt?(dir),
|
|
82
|
+
"bridge_present" => !bridge.nil?,
|
|
83
|
+
"agreement" => (lock && bridge) ?
|
|
84
|
+
(lock["owner_session"] == bridge.dig("lock", "owner_session")) : nil,
|
|
85
|
+
"claims" => Claim.claims_status(dir),
|
|
86
|
+
}
|
|
87
|
+
puts JSON.pretty_generate(report)
|
|
88
|
+
when "fix"
|
|
89
|
+
report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
|
|
90
|
+
store: store, name: name)
|
|
91
|
+
puts JSON.pretty_generate(report)
|
|
92
|
+
unless report["status"] == "repaired"
|
|
93
|
+
warn "plastic-lock: #{report['status']} by #{report['owner']}" \
|
|
94
|
+
"#{report['hint'] ? " (#{report['hint']})" : ''}"
|
|
95
|
+
exit 1
|
|
96
|
+
end
|
|
97
|
+
when "release"
|
|
98
|
+
result = Lock.release(dir, session: key)
|
|
99
|
+
if result == :not_owner
|
|
100
|
+
warn "plastic-lock: not the owner; run plastic-lock status"
|
|
101
|
+
exit 1
|
|
102
|
+
end
|
|
103
|
+
data = Bridge.read(key)
|
|
104
|
+
if data
|
|
105
|
+
data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
|
|
106
|
+
"host" => nil, "type" => nil, "delegates" => [] }
|
|
107
|
+
Bridge.write(key, data)
|
|
108
|
+
end
|
|
109
|
+
puts "released (#{result})"
|
|
110
|
+
when "reclaim"
|
|
111
|
+
status, lock_data = Lock.takeover(dir, session: key)
|
|
112
|
+
if status == :fresh
|
|
113
|
+
warn "plastic-lock: lock is FRESH and held by #{lock_data['owner_session']}; " \
|
|
114
|
+
"back off (no silent reclaim)"
|
|
115
|
+
exit 1
|
|
116
|
+
end
|
|
117
|
+
report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
|
|
118
|
+
store: store, name: name)
|
|
119
|
+
puts JSON.pretty_generate(report)
|
|
120
|
+
when "delegate"
|
|
121
|
+
usage! if opts[:delegate].nil?
|
|
122
|
+
ok = Lock.add_delegate(dir, delegate: opts[:delegate], session: key)
|
|
123
|
+
unless ok
|
|
124
|
+
warn "plastic-lock: only the lock owner may delegate; run plastic-lock status"
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
puts "delegated #{opts[:delegate]} under #{key}"
|
|
128
|
+
when "claim"
|
|
129
|
+
usage! if opts[:artifact].nil?
|
|
130
|
+
status, data = Claim.acquire_claim(dir, opts[:artifact], session: key,
|
|
131
|
+
delegate: opts[:delegate])
|
|
132
|
+
case status
|
|
133
|
+
when :acquired
|
|
134
|
+
puts "claimed #{opts[:artifact]} under #{key}"
|
|
135
|
+
when :held
|
|
136
|
+
warn "plastic-lock: #{opts[:artifact]} is claimed by " \
|
|
137
|
+
"#{data['owner_session']} since #{data['acquired_at']}; back off"
|
|
138
|
+
exit 1
|
|
139
|
+
when :stale
|
|
140
|
+
old = data && data["owner_session"]
|
|
141
|
+
Claim.release_claim(dir, opts[:artifact], session: key, force: true)
|
|
142
|
+
status2, = Claim.acquire_claim(dir, opts[:artifact], session: key,
|
|
143
|
+
delegate: opts[:delegate])
|
|
144
|
+
if status2 == :acquired
|
|
145
|
+
warn "plastic-lock: took over stale claim on #{opts[:artifact]} from #{old}"
|
|
146
|
+
puts "claimed #{opts[:artifact]} under #{key}"
|
|
147
|
+
else
|
|
148
|
+
warn "plastic-lock: could not take over #{opts[:artifact]} (#{status2})"
|
|
149
|
+
exit 1
|
|
150
|
+
end
|
|
151
|
+
when :corrupt
|
|
152
|
+
Claim.release_claim(dir, opts[:artifact], session: key, force: true)
|
|
153
|
+
Claim.acquire_claim(dir, opts[:artifact], session: key, delegate: opts[:delegate])
|
|
154
|
+
puts "claimed #{opts[:artifact]} under #{key} (repaired corrupt claim)"
|
|
155
|
+
end
|
|
156
|
+
when "release-claim"
|
|
157
|
+
usage! if opts[:artifact].nil?
|
|
158
|
+
result = Claim.release_claim(dir, opts[:artifact], session: key)
|
|
159
|
+
if result == :not_owner
|
|
160
|
+
warn "plastic-lock: not the claim owner; run plastic-lock status"
|
|
161
|
+
exit 1
|
|
162
|
+
end
|
|
163
|
+
puts "released-claim #{opts[:artifact]} (#{result})"
|
|
164
|
+
end
|
|
@@ -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
|