@zalom/plastic 1.4.0 → 1.5.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 +2 -0
- package/PLASTIC.md +160 -42
- package/agents/plastic-intent-curator.md +10 -2
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +271 -15
- package/scripts/end-intent +32 -7
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +79 -30
- package/scripts/lib/hook_registry.rb +44 -2
- package/scripts/lib/installer_core.rb +45 -6
- package/scripts/lib/lock.rb +186 -11
- package/scripts/lib/maintenance_git.rb +94 -0
- package/scripts/lib/revisions_writer.rb +69 -0
- package/scripts/lib/worktree.rb +14 -32
- package/scripts/lib/worktree_sweep.rb +129 -0
- package/scripts/maintenance-run +236 -0
- package/scripts/plastic-lock +76 -9
- package/scripts/project-links +127 -24
- package/scripts/rebuild-graph +37 -3
- package/scripts/restore-intent-v1 +37 -3
- package/scripts/sweep-store-worktrees +53 -0
- package/skills/auto/SKILL.md +29 -6
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +8 -6
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- package/skills/doctor/SKILL.md +6 -0
- package/skills/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- package/skills/releasing/SKILL.md +3 -3
- package/skills/store-curating/SKILL.md +9 -0
- package/skills/store-curating/evals/evals.json +16 -0
- package/skills/tutorial/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -1
package/scripts/plastic-lock
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
# plastic-lock: inspect and repair the durable delivery lock (intent 108, D5),
|
|
6
6
|
# and take/free per-artifact claim tokens (intent 111 D1/D5).
|
|
7
7
|
#
|
|
8
|
-
# Usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim>
|
|
8
|
+
# Usage: plastic-lock <status|who|fix|release|reclaim|delegate|claim|release-claim>
|
|
9
9
|
# [--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]
|
|
10
10
|
#
|
|
11
11
|
# Verbs:
|
|
12
|
+
# who compact, read-only durable owner/activity view
|
|
12
13
|
# status report the lock file, the bridge cache, their agreement,
|
|
13
14
|
# and any live per-artifact claims
|
|
14
15
|
# fix idempotent repair: rebuild lock + bridge from disk truth for
|
|
@@ -29,13 +30,15 @@ require_relative "lib/bridge"
|
|
|
29
30
|
require_relative "lib/lock"
|
|
30
31
|
|
|
31
32
|
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]"
|
|
33
|
+
warn "usage: plastic-lock <status|who|fix|release|reclaim|delegate|claim|release-claim> " \
|
|
34
|
+
"[--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME] " \
|
|
35
|
+
"[--harness claude|codex] [--agent NAME] [--model MODEL] [--thread ID] " \
|
|
36
|
+
"[--mode auto|guided] [--status finished|failed]"
|
|
34
37
|
exit 1
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
verb = ARGV.shift
|
|
38
|
-
usage! unless %w[status fix release reclaim delegate claim release-claim].include?(verb)
|
|
41
|
+
usage! unless %w[status who fix release reclaim delegate claim release-claim].include?(verb)
|
|
39
42
|
|
|
40
43
|
opts = {}
|
|
41
44
|
until ARGV.empty?
|
|
@@ -44,17 +47,30 @@ until ARGV.empty?
|
|
|
44
47
|
when "--session" then opts[:session] = ARGV.shift
|
|
45
48
|
when "--delegate" then opts[:delegate] = ARGV.shift
|
|
46
49
|
when "--artifact" then opts[:artifact] = ARGV.shift
|
|
50
|
+
when "--harness" then opts[:harness] = ARGV.shift
|
|
51
|
+
when "--agent" then opts[:agent] = ARGV.shift
|
|
52
|
+
when "--model" then opts[:model] = ARGV.shift
|
|
53
|
+
when "--thread" then opts[:thread] = ARGV.shift
|
|
54
|
+
when "--mode" then opts[:mode] = ARGV.shift
|
|
55
|
+
when "--status" then opts[:status] = ARGV.shift
|
|
47
56
|
else
|
|
48
57
|
warn "unknown flag #{flag}"
|
|
49
58
|
usage!
|
|
50
59
|
end
|
|
51
60
|
end
|
|
61
|
+
usage! if opts[:mode] && !%w[auto guided].include?(opts[:mode])
|
|
52
62
|
|
|
53
63
|
session = opts[:session]
|
|
54
64
|
session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.strip.empty?
|
|
65
|
+
harness = opts[:harness]
|
|
66
|
+
hint_harness = harness || "claude"
|
|
55
67
|
|
|
56
68
|
dir = opts[:dir]
|
|
57
69
|
if dir.nil? || dir.strip.empty?
|
|
70
|
+
if verb == "who"
|
|
71
|
+
warn "plastic-lock: who is strictly durable-state only; pass --intent-dir <intent dir>"
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
58
74
|
bridge = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
59
75
|
dir = Bridge.bridge_intent_dir(bridge)
|
|
60
76
|
end
|
|
@@ -64,6 +80,37 @@ if dir.nil?
|
|
|
64
80
|
end
|
|
65
81
|
dir = File.expand_path(dir)
|
|
66
82
|
|
|
83
|
+
if verb == "who"
|
|
84
|
+
view = Lock.who(dir)
|
|
85
|
+
basename = File.basename(dir)
|
|
86
|
+
intent_id, slug = basename.split("--", 2)
|
|
87
|
+
display = ->(value) { value.nil? || value.to_s.strip.empty? || value == "unknown" ? "Unknown" : value.to_s }
|
|
88
|
+
harness = ->(value) {
|
|
89
|
+
shown = display.call(value)
|
|
90
|
+
shown == "Unknown" ? shown : shown.sub(/\A./) { |char| char.upcase }
|
|
91
|
+
}
|
|
92
|
+
owner = view["owner"] || {}
|
|
93
|
+
claims = Array(view["claims"]).select { |claim| claim["fresh"] && !claim["corrupt"] }
|
|
94
|
+
claim_text = claims.map do |claim|
|
|
95
|
+
writer = claim["delegate"] || claim["owner_session"]
|
|
96
|
+
"#{display.call(claim['artifact'])} by #{display.call(writer)}"
|
|
97
|
+
end
|
|
98
|
+
delegate = Array(view["delegates"]).last
|
|
99
|
+
|
|
100
|
+
puts "#{intent_id} · #{slug || basename}"
|
|
101
|
+
puts "State: #{view['state']}"
|
|
102
|
+
puts "Controller: #{display.call(owner['agent'])} via #{harness.call(owner['harness'])}"
|
|
103
|
+
puts "Session: #{display.call(view['owner_session'])}"
|
|
104
|
+
puts "Heartbeat: #{view['heartbeat_at'] || 'none'}"
|
|
105
|
+
puts "Claims: #{claim_text.empty? ? 'none' : claim_text.join(', ')}"
|
|
106
|
+
if delegate
|
|
107
|
+
puts "Delegate: #{display.call(delegate['agent'])} via #{harness.call(delegate['harness'])}, #{display.call(delegate['status'])}"
|
|
108
|
+
else
|
|
109
|
+
puts "Delegate: none"
|
|
110
|
+
end
|
|
111
|
+
exit 0
|
|
112
|
+
end
|
|
113
|
+
|
|
67
114
|
intent_id = Bridge.intent_id_from_dir(dir)
|
|
68
115
|
store = File.dirname(dir)
|
|
69
116
|
name = File.basename(dir)
|
|
@@ -87,7 +134,10 @@ when "status"
|
|
|
87
134
|
puts JSON.pretty_generate(report)
|
|
88
135
|
when "fix"
|
|
89
136
|
report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
|
|
90
|
-
store: store, name: name
|
|
137
|
+
store: store, name: name, harness: harness,
|
|
138
|
+
agent: opts[:agent], model: opts[:model], thread: opts[:thread],
|
|
139
|
+
run_mode: opts[:mode],
|
|
140
|
+
hint_harness: hint_harness)
|
|
91
141
|
puts JSON.pretty_generate(report)
|
|
92
142
|
unless report["status"] == "repaired"
|
|
93
143
|
warn "plastic-lock: #{report['status']} by #{report['owner']}" \
|
|
@@ -108,23 +158,40 @@ when "release"
|
|
|
108
158
|
end
|
|
109
159
|
puts "released (#{result})"
|
|
110
160
|
when "reclaim"
|
|
111
|
-
status, lock_data = Lock.takeover(dir, session: key
|
|
161
|
+
status, lock_data = Lock.takeover(dir, session: key, harness: harness,
|
|
162
|
+
agent: opts[:agent], model: opts[:model],
|
|
163
|
+
thread: opts[:thread], run_mode: opts[:mode])
|
|
112
164
|
if status == :fresh
|
|
113
165
|
warn "plastic-lock: lock is FRESH and held by #{lock_data['owner_session']}; " \
|
|
114
166
|
"back off (no silent reclaim)"
|
|
115
167
|
exit 1
|
|
116
168
|
end
|
|
117
169
|
report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
|
|
118
|
-
store: store, name: name
|
|
170
|
+
store: store, name: name, harness: harness,
|
|
171
|
+
agent: opts[:agent], model: opts[:model], thread: opts[:thread],
|
|
172
|
+
run_mode: opts[:mode],
|
|
173
|
+
hint_harness: hint_harness)
|
|
119
174
|
puts JSON.pretty_generate(report)
|
|
120
175
|
when "delegate"
|
|
121
176
|
usage! if opts[:delegate].nil?
|
|
122
|
-
|
|
177
|
+
usage! if opts[:status] && !%w[finished failed].include?(opts[:status])
|
|
178
|
+
ok = if opts[:status]
|
|
179
|
+
Lock.update_delegate_status(dir, delegate: opts[:delegate],
|
|
180
|
+
status: opts[:status], session: key)
|
|
181
|
+
else
|
|
182
|
+
Lock.add_delegate(dir, delegate: opts[:delegate], session: key,
|
|
183
|
+
harness: harness, agent: opts[:agent], model: opts[:model],
|
|
184
|
+
thread: opts[:thread])
|
|
185
|
+
end
|
|
123
186
|
unless ok
|
|
124
187
|
warn "plastic-lock: only the lock owner may delegate; run plastic-lock status"
|
|
125
188
|
exit 1
|
|
126
189
|
end
|
|
127
|
-
|
|
190
|
+
if opts[:status]
|
|
191
|
+
puts "delegate #{opts[:delegate]} marked #{opts[:status]} under #{key}"
|
|
192
|
+
else
|
|
193
|
+
puts "delegated #{opts[:delegate]} under #{key}"
|
|
194
|
+
end
|
|
128
195
|
when "claim"
|
|
129
196
|
usage! if opts[:artifact].nil?
|
|
130
197
|
status, data = Claim.acquire_claim(dir, opts[:artifact], session: key,
|
package/scripts/project-links
CHANGED
|
@@ -18,11 +18,16 @@
|
|
|
18
18
|
# reported in the audit, never silently deleted. Pass --drop-unbacked-links to
|
|
19
19
|
# delete orphan candidates deliberately (reported separately from a silent
|
|
20
20
|
# drop). A line that resolves to nothing (a broken or mistyped wikilink) is
|
|
21
|
-
# still dropped
|
|
22
|
-
#
|
|
21
|
+
# still dropped from the file (it names no real relationship to keep), but
|
|
22
|
+
# reported in the audit as a malformed-reference finding rather than
|
|
23
|
+
# vanishing without a trace (intent 197).
|
|
23
24
|
#
|
|
24
25
|
# Usage:
|
|
25
26
|
# project-links [--plastic-home PATH] [--dry-run] [--audit-path PATH] [--drop-unbacked-links]
|
|
27
|
+
# [--intent <id> [--store <key>]]
|
|
28
|
+
#
|
|
29
|
+
# --store only matters when --intent alone is ambiguous across stores (the tool
|
|
30
|
+
# aborts loud, naming every candidate store, rather than silently picking one).
|
|
26
31
|
#
|
|
27
32
|
# Pure-Ruby (no bash). The pure logic lives in lib/links_projection.rb and
|
|
28
33
|
# lib/links_section.rb; this shell does only discovery, IO, and reporting. It reads
|
|
@@ -38,6 +43,7 @@ require_relative "lib/graph_rebuild"
|
|
|
38
43
|
require_relative "lib/links_projection"
|
|
39
44
|
require_relative "lib/links_section"
|
|
40
45
|
require_relative "lib/store_discovery"
|
|
46
|
+
require_relative "lib/revisions_writer"
|
|
41
47
|
|
|
42
48
|
class ProjectLinks
|
|
43
49
|
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
@@ -47,10 +53,12 @@ class ProjectLinks
|
|
|
47
53
|
"projects/plastic/store/72--links-graph-projection/resources/audit--links-projection.md"
|
|
48
54
|
|
|
49
55
|
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil,
|
|
50
|
-
drop_unbacked_links: false)
|
|
56
|
+
drop_unbacked_links: false, intent: nil, store: nil)
|
|
51
57
|
@plastic_home = plastic_home
|
|
52
58
|
@dry_run = dry_run
|
|
53
59
|
@drop_unbacked_links = drop_unbacked_links
|
|
60
|
+
@intent = intent && intent.to_s
|
|
61
|
+
@store = store
|
|
54
62
|
|
|
55
63
|
# A dry run must NOT stomp the canonical audit (humans run --dry-run to review
|
|
56
64
|
# the plan). With no explicit --audit-path, a dry run writes a `.dry-run.md`
|
|
@@ -68,7 +76,7 @@ class ProjectLinks
|
|
|
68
76
|
end
|
|
69
77
|
end
|
|
70
78
|
|
|
71
|
-
attr_reader :plastic_home, :dry_run, :audit_path, :drop_unbacked_links
|
|
79
|
+
attr_reader :plastic_home, :dry_run, :audit_path, :drop_unbacked_links, :intent, :store
|
|
72
80
|
|
|
73
81
|
# Every store in scope: global plus every projects/<slug>/store directory that exists
|
|
74
82
|
# (intent 189). Shares its definition with rebuild-graph, doctor.rb, and new-intent via
|
|
@@ -130,13 +138,23 @@ class ProjectLinks
|
|
|
130
138
|
|
|
131
139
|
relocation_map = GraphRebuild.build_relocation_map(index_texts)
|
|
132
140
|
|
|
141
|
+
target_store_key = intent ? resolve_target_store(store_list, nodes_by_store, intent) : nil
|
|
142
|
+
|
|
133
143
|
results = {}
|
|
134
144
|
store_list.each do |s|
|
|
135
145
|
key = s[:key]
|
|
136
|
-
|
|
137
|
-
key
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
if intent
|
|
147
|
+
results[key] = (key == target_store_key) ? project_store(
|
|
148
|
+
key, nodes_by_store[key],
|
|
149
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index,
|
|
150
|
+
only: intent
|
|
151
|
+
) : { entries: [], counts: Hash.new(0) }
|
|
152
|
+
else
|
|
153
|
+
results[key] = project_store(
|
|
154
|
+
key, nodes_by_store[key],
|
|
155
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index
|
|
156
|
+
)
|
|
157
|
+
end
|
|
140
158
|
end
|
|
141
159
|
|
|
142
160
|
emit_audit(store_list, results)
|
|
@@ -146,9 +164,10 @@ class ProjectLinks
|
|
|
146
164
|
# Project every intent in ONE store. Returns
|
|
147
165
|
# { entries: [ {id:, status:, before:, after:, error:, orphans_preserved:,
|
|
148
166
|
# orphans_dropped_optin:} ], counts: {...} }.
|
|
149
|
-
def project_store(referer_store, nodes, relocation_map:, store_index:, node_index:)
|
|
167
|
+
def project_store(referer_store, nodes, relocation_map:, store_index:, node_index:, only: nil)
|
|
150
168
|
entries = []
|
|
151
169
|
nodes.each do |id, node|
|
|
170
|
+
next if only && id != only
|
|
152
171
|
resolve = ->(ref) do
|
|
153
172
|
LinksProjection.resolve_ref_projection(
|
|
154
173
|
ref, referer_store: referer_store,
|
|
@@ -164,7 +183,7 @@ class ProjectLinks
|
|
|
164
183
|
canonical_text = LinksProjection.section(
|
|
165
184
|
sources: node[:sources], chain: node[:chain], resolve: resolve
|
|
166
185
|
)
|
|
167
|
-
kept, dropped_optin = orphan_split(old_text, canonical_text, referer_store,
|
|
186
|
+
kept, dropped_optin, dead = orphan_split(old_text, canonical_text, referer_store,
|
|
168
187
|
store_index, node_index)
|
|
169
188
|
section_text = LinksProjection.render_entries(
|
|
170
189
|
LinksProjection.parse_entries(canonical_text) + kept
|
|
@@ -172,42 +191,107 @@ class ProjectLinks
|
|
|
172
191
|
updated = LinksSection.rewrite(content, section_text)
|
|
173
192
|
rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
|
|
174
193
|
entries << { id: id, status: :failed, error: e.message,
|
|
175
|
-
orphans_preserved: [], orphans_dropped_optin: [] }
|
|
194
|
+
orphans_preserved: [], orphans_dropped_optin: [], orphans_dead: [] }
|
|
176
195
|
next
|
|
177
196
|
end
|
|
178
197
|
|
|
179
198
|
if updated == content
|
|
180
|
-
entries << { id: id, status: :unchanged,
|
|
181
|
-
|
|
199
|
+
entries << { id: id, status: :unchanged, orphans_preserved: kept,
|
|
200
|
+
orphans_dropped_optin: dropped_optin, orphans_dead: dead }
|
|
182
201
|
next
|
|
183
202
|
end
|
|
184
203
|
|
|
185
204
|
status = had_links ? :regenerated : :added
|
|
186
|
-
|
|
205
|
+
|
|
206
|
+
unless dry_run
|
|
207
|
+
begin
|
|
208
|
+
RevisionsWriter.append!(
|
|
209
|
+
File.dirname(node[:path]),
|
|
210
|
+
why: revision_why(status, kept, dropped_optin, dead),
|
|
211
|
+
rule: "links-projection",
|
|
212
|
+
prior_location: "#{node[:basename]}.md ## Links",
|
|
213
|
+
change: revision_change(old_text, section_text)
|
|
214
|
+
)
|
|
215
|
+
rescue RevisionsWriter::WriteFailed => e
|
|
216
|
+
entries << { id: id, status: :failed, error: e.message,
|
|
217
|
+
orphans_preserved: [], orphans_dropped_optin: [], orphans_dead: [] }
|
|
218
|
+
next
|
|
219
|
+
end
|
|
220
|
+
File.write(node[:path], updated)
|
|
221
|
+
end
|
|
222
|
+
|
|
187
223
|
entries << { id: id, status: status, before: old_text, after: section_text,
|
|
188
|
-
orphans_preserved: kept, orphans_dropped_optin: dropped_optin }
|
|
224
|
+
orphans_preserved: kept, orphans_dropped_optin: dropped_optin, orphans_dead: dead }
|
|
189
225
|
end
|
|
190
226
|
|
|
191
227
|
counts = entries.each_with_object(Hash.new(0)) { |e, h| h[e[:status]] += 1 }
|
|
192
228
|
{ entries: entries, counts: counts }
|
|
193
229
|
end
|
|
194
230
|
|
|
231
|
+
# Resolves --intent to exactly ONE store key, aborting loud on ambiguity (mirrors
|
|
232
|
+
# scripts/restore-intent-v1's find_intent_dir, which solves this identical cross-store
|
|
233
|
+
# id-collision problem the same way). `store:` (explicit --store) short-circuits resolution
|
|
234
|
+
# when the caller already knows which store; otherwise every store containing a matching
|
|
235
|
+
# id is a candidate, and more than one is a hard abort (never silently pick one). An id
|
|
236
|
+
# found in NO store is not an error (unlike restore-intent-v1's higher blast radius): it
|
|
237
|
+
# resolves to nil, so every store gets the synthetic zero-activity result in `run` (a
|
|
238
|
+
# provably observable no-op, not a crash) rather than aborting a caller that may be
|
|
239
|
+
# running this defensively (e.g. before an id is known to exist yet).
|
|
240
|
+
def resolve_target_store(store_list, nodes_by_store, intent)
|
|
241
|
+
if store
|
|
242
|
+
abort "project-links: --store #{store.inspect} has no intent #{intent.inspect}" \
|
|
243
|
+
unless nodes_by_store[store]&.key?(intent)
|
|
244
|
+
return store
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
candidates = store_list.select { |s| nodes_by_store[s[:key]].key?(intent) }.map { |s| s[:key] }
|
|
248
|
+
return nil if candidates.empty?
|
|
249
|
+
if candidates.length > 1
|
|
250
|
+
abort "project-links: intent #{intent.inspect} is ambiguous across stores " \
|
|
251
|
+
"(#{candidates.join(", ")}); pass --store <key> to disambiguate (e.g. --store " \
|
|
252
|
+
"#{candidates.first})"
|
|
253
|
+
end
|
|
254
|
+
candidates.first
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Renders the one-sentence "Why" for a project-links-authored receipt. Free text; the
|
|
258
|
+
# [rule: tag] suffix is appended by RevisionsWriter itself, not here.
|
|
259
|
+
def revision_why(status, kept, dropped_optin, dead)
|
|
260
|
+
parts = []
|
|
261
|
+
parts << (status == :added ? "added a missing ## Links section" : "regenerated ## Links to match frontmatter")
|
|
262
|
+
parts << "dropped #{dead.size} unresolvable reference(s)" unless dead.empty?
|
|
263
|
+
parts << "dropped #{dropped_optin.size} unbacked-but-resolvable reference(s) (--drop-unbacked-links)" unless dropped_optin.empty?
|
|
264
|
+
parts.join("; ")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def revision_change(before_text, after_text)
|
|
268
|
+
"## Links regenerated (before -> after)\n\n BEFORE:\n" \
|
|
269
|
+
"#{block_lines(before_text).map { |l| " #{l}" }.join("\n")}\n\n AFTER:\n" \
|
|
270
|
+
"#{block_lines(after_text).map { |l| " #{l}" }.join("\n")}"
|
|
271
|
+
end
|
|
272
|
+
|
|
195
273
|
# Split the OLD (pre-mutation) `## Links` entries into orphans to KEEP
|
|
196
274
|
# (unbacked by frontmatter but resolve to a real, currently-discovered
|
|
197
275
|
# intent) vs DROP under the explicit --drop-unbacked-links opt-in. An entry
|
|
198
|
-
# that resolves nowhere is neither: it is dropped
|
|
199
|
-
#
|
|
200
|
-
# garbage, not data).
|
|
201
|
-
# [
|
|
276
|
+
# that resolves nowhere is neither: it is dropped from the file (nothing real to
|
|
277
|
+
# preserve) but reported as a malformed-reference finding, not silently (intent 197;
|
|
278
|
+
# this is dealintell 3b's three broken single-dash lines: garbage, not data).
|
|
279
|
+
# Returns [kept_entries, dropped_optin_entries, dead_entries].
|
|
202
280
|
def orphan_split(old_text, canonical_text, referer_store, store_index, node_index)
|
|
203
281
|
canonical_targets = LinksProjection.parse_entries(canonical_text).map { |e| e[:target] }
|
|
204
282
|
kept = []
|
|
205
283
|
dropped_optin = []
|
|
284
|
+
dead = []
|
|
206
285
|
LinksProjection.parse_entries(old_text).each do |oe|
|
|
207
286
|
next if canonical_targets.include?(oe[:target]) # already backed; not an orphan
|
|
208
287
|
|
|
209
288
|
label = resolve_orphan_label(oe[:target], referer_store, store_index, node_index)
|
|
210
|
-
|
|
289
|
+
if label.nil?
|
|
290
|
+
# Resolves to nothing: still dropped from the file (nothing real to preserve),
|
|
291
|
+
# but no longer silent. Reported as a malformed-orphan candidate in the audit.
|
|
292
|
+
dead << { target: oe[:target] }
|
|
293
|
+
next
|
|
294
|
+
end
|
|
211
295
|
|
|
212
296
|
if drop_unbacked_links
|
|
213
297
|
dropped_optin << oe
|
|
@@ -215,7 +299,7 @@ class ProjectLinks
|
|
|
215
299
|
kept << { target: oe[:target], label: label }
|
|
216
300
|
end
|
|
217
301
|
end
|
|
218
|
-
[kept, dropped_optin]
|
|
302
|
+
[kept, dropped_optin, dead]
|
|
219
303
|
end
|
|
220
304
|
|
|
221
305
|
# Does `target` (already in projected `id--slug` or `store:id--slug` form)
|
|
@@ -267,6 +351,7 @@ class ProjectLinks
|
|
|
267
351
|
lines << "# Audit: store-wide ## Links projection (intent 72)"
|
|
268
352
|
lines << ""
|
|
269
353
|
lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
|
|
354
|
+
lines << "Scoped to intent #{intent} only (--intent)." if intent
|
|
270
355
|
lines << ""
|
|
271
356
|
|
|
272
357
|
total = STATUS_ORDER.to_h do |st|
|
|
@@ -274,11 +359,13 @@ class ProjectLinks
|
|
|
274
359
|
end
|
|
275
360
|
total_preserved = store_list.sum { |s| results[s[:key]][:entries].sum { |e| Array(e[:orphans_preserved]).size } }
|
|
276
361
|
total_dropped_optin = store_list.sum { |s| results[s[:key]][:entries].sum { |e| Array(e[:orphans_dropped_optin]).size } }
|
|
362
|
+
total_dead = store_list.sum { |s| results[s[:key]][:entries].sum { |e| Array(e[:orphans_dead]).size } }
|
|
277
363
|
lines << "Totals across all stores: " \
|
|
278
364
|
"regenerated #{total[:regenerated]}, added #{total[:added]}, " \
|
|
279
365
|
"unchanged #{total[:unchanged]}, failed #{total[:failed]}. " \
|
|
280
366
|
"Unbacked Links lines preserved as orphan candidates: #{total_preserved}" \
|
|
281
|
-
"#{drop_unbacked_links ? ", dropped via --drop-unbacked-links: #{total_dropped_optin}" : ""}
|
|
367
|
+
"#{drop_unbacked_links ? ", dropped via --drop-unbacked-links: #{total_dropped_optin}" : ""}" \
|
|
368
|
+
", malformed references found: #{total_dead}."
|
|
282
369
|
lines << ""
|
|
283
370
|
|
|
284
371
|
unless missing_stores.empty?
|
|
@@ -321,6 +408,17 @@ class ProjectLinks
|
|
|
321
408
|
lines << ""
|
|
322
409
|
end
|
|
323
410
|
|
|
411
|
+
dead_refs = res[:entries].flat_map { |e| Array(e[:orphans_dead]).map { |o| [e[:id], o] } }
|
|
412
|
+
unless dead_refs.empty?
|
|
413
|
+
lines << "### Malformed references found, dropped silently before this fix (#{dead_refs.size})"
|
|
414
|
+
lines << "Resolve to no real intent under any known store; these are dropped from the " \
|
|
415
|
+
"regenerated ## Links section (nothing real to preserve), but were previously " \
|
|
416
|
+
"invisible to every check. Confirm each is genuinely dead (a typo'd slug, a " \
|
|
417
|
+
"sibling wrongly linked) rather than a missing store/relocation before trusting."
|
|
418
|
+
dead_refs.each { |id, o| lines << "- #{id}: -> #{o[:target]}" }
|
|
419
|
+
lines << ""
|
|
420
|
+
end
|
|
421
|
+
|
|
324
422
|
sample = res[:entries].select { |e| %i[regenerated added].include?(e[:status]) }.first(5)
|
|
325
423
|
next if sample.empty?
|
|
326
424
|
|
|
@@ -356,6 +454,8 @@ if $PROGRAM_NAME == __FILE__
|
|
|
356
454
|
dry = false
|
|
357
455
|
audit = nil
|
|
358
456
|
drop = false
|
|
457
|
+
intent = nil
|
|
458
|
+
store = nil
|
|
359
459
|
i = 0
|
|
360
460
|
while i < ARGV.length
|
|
361
461
|
case ARGV[i]
|
|
@@ -363,14 +463,17 @@ if $PROGRAM_NAME == __FILE__
|
|
|
363
463
|
when "--dry-run" then dry = true; i += 1
|
|
364
464
|
when "--audit-path" then audit = ARGV[i + 1]; i += 2
|
|
365
465
|
when "--drop-unbacked-links" then drop = true; i += 1
|
|
466
|
+
when "--intent" then intent = ARGV[i + 1]; i += 2
|
|
467
|
+
when "--store" then store = ARGV[i + 1]; i += 2
|
|
366
468
|
else
|
|
367
469
|
abort "project-links: unknown argument #{ARGV[i].inspect} " \
|
|
368
|
-
"(usage: --plastic-home PATH | --dry-run | --audit-path PATH |
|
|
470
|
+
"(usage: --plastic-home PATH | --dry-run | --audit-path PATH | " \
|
|
471
|
+
"--drop-unbacked-links | --intent <id> [--store <key>])"
|
|
369
472
|
end
|
|
370
473
|
end
|
|
371
474
|
|
|
372
475
|
tool = ProjectLinks.new(plastic_home: home, dry_run: dry, audit_path: audit,
|
|
373
|
-
drop_unbacked_links: drop)
|
|
476
|
+
drop_unbacked_links: drop, intent: intent, store: store)
|
|
374
477
|
results = tool.run
|
|
375
478
|
totals = ProjectLinks::STATUS_ORDER.to_h do |st|
|
|
376
479
|
[st, results.values.sum { |r| r[:counts][st] }]
|
package/scripts/rebuild-graph
CHANGED
|
@@ -25,6 +25,7 @@ require_relative "lib/graph_rebuild"
|
|
|
25
25
|
require_relative "lib/frontmatter_writer"
|
|
26
26
|
require_relative "lib/intent_validator"
|
|
27
27
|
require_relative "lib/store_discovery"
|
|
28
|
+
require_relative "lib/revisions_writer"
|
|
28
29
|
|
|
29
30
|
class RebuildGraph
|
|
30
31
|
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
@@ -47,6 +48,7 @@ class RebuildGraph
|
|
|
47
48
|
def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
|
|
48
49
|
@plastic_home = plastic_home
|
|
49
50
|
@dry_run = dry_run
|
|
51
|
+
@write_failures = []
|
|
50
52
|
|
|
51
53
|
# A dry run must NOT stomp the canonical audit (the spec/checklist tell humans
|
|
52
54
|
# to run --dry-run to review the plan). When no explicit --audit-path is given,
|
|
@@ -64,7 +66,11 @@ class RebuildGraph
|
|
|
64
66
|
end
|
|
65
67
|
end
|
|
66
68
|
|
|
67
|
-
attr_reader :plastic_home, :dry_run, :audit_path
|
|
69
|
+
attr_reader :plastic_home, :dry_run, :audit_path, :write_failures
|
|
70
|
+
|
|
71
|
+
def any_write_failures?
|
|
72
|
+
!write_failures.empty?
|
|
73
|
+
end
|
|
68
74
|
|
|
69
75
|
# Every store in scope: global plus every projects/<slug>/store directory that exists
|
|
70
76
|
# (intent 189). A superset of reality, not a hardcoded list: missing a real store here
|
|
@@ -140,11 +146,15 @@ class RebuildGraph
|
|
|
140
146
|
results
|
|
141
147
|
end
|
|
142
148
|
|
|
143
|
-
# Write changed frontmatter back via the minimal style-preserving writer.
|
|
149
|
+
# Write changed frontmatter back via the minimal style-preserving writer. Every applied
|
|
150
|
+
# change writes a revisions.md receipt FIRST (intent 197): if the receipt cannot be
|
|
151
|
+
# written, the frontmatter change is withheld (reported in write_failures) rather than
|
|
152
|
+
# left unrecorded.
|
|
144
153
|
def write_back(store_list, nodes_by_store, results)
|
|
145
154
|
store_list.each do |s|
|
|
146
155
|
key = s[:key]
|
|
147
156
|
new_nodes = results[key][:nodes]
|
|
157
|
+
changes_by_id = results[key][:changes].group_by { |c| c[:intent] }
|
|
148
158
|
nodes_by_store[key].each do |id, original|
|
|
149
159
|
rebuilt = new_nodes[id]
|
|
150
160
|
next if rebuilt.nil?
|
|
@@ -154,7 +164,22 @@ class RebuildGraph
|
|
|
154
164
|
updated = FrontmatterWriter.rewrite_arrays(content,
|
|
155
165
|
sources: rebuilt[:sources],
|
|
156
166
|
chain: rebuilt[:chain])
|
|
157
|
-
|
|
167
|
+
next if updated == content
|
|
168
|
+
|
|
169
|
+
id_changes = changes_by_id[id] || []
|
|
170
|
+
begin
|
|
171
|
+
RevisionsWriter.append!(
|
|
172
|
+
File.dirname(original[:path]),
|
|
173
|
+
why: "graph rebuild: #{id_changes.map { |c| c[:kind] }.uniq.join(", ")}",
|
|
174
|
+
rule: "graph-rebuild",
|
|
175
|
+
prior_location: "#{File.basename(original[:path], ".md")}.md frontmatter - sources/chain",
|
|
176
|
+
change: id_changes.map { |c| format_change(c) }.join("; ")
|
|
177
|
+
)
|
|
178
|
+
rescue RevisionsWriter::WriteFailed => e
|
|
179
|
+
@write_failures << { id: id, error: e.message }
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
File.write(original[:path], updated)
|
|
158
183
|
end
|
|
159
184
|
end
|
|
160
185
|
end
|
|
@@ -191,6 +216,13 @@ class RebuildGraph
|
|
|
191
216
|
lines << ""
|
|
192
217
|
end
|
|
193
218
|
|
|
219
|
+
unless write_failures.empty?
|
|
220
|
+
lines << "## Frontmatter changes that could NOT be written (revisions.md receipt failed)"
|
|
221
|
+
lines << ""
|
|
222
|
+
write_failures.each { |f| lines << "- #{f[:id]}: #{f[:error]}" }
|
|
223
|
+
lines << ""
|
|
224
|
+
end
|
|
225
|
+
|
|
194
226
|
unless missing_stores.empty?
|
|
195
227
|
lines << "## Registered projects with no store on disk"
|
|
196
228
|
lines << ""
|
|
@@ -266,4 +298,6 @@ if $PROGRAM_NAME == __FILE__
|
|
|
266
298
|
puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
|
|
267
299
|
puts "Unknown-store refs preserved (not dropped): #{preserved}" if preserved.positive?
|
|
268
300
|
puts "Audit: #{tool.audit_path}"
|
|
301
|
+
puts "Write failures (receipt could not be recorded, change withheld): #{tool.write_failures.size}" if tool.any_write_failures?
|
|
302
|
+
exit 1 if tool.any_write_failures?
|
|
269
303
|
end
|
|
@@ -45,6 +45,7 @@ require_relative "lib/store_discovery"
|
|
|
45
45
|
require_relative "lib/intent_validator"
|
|
46
46
|
require_relative "lib/graph_rebuild"
|
|
47
47
|
require_relative "lib/frontmatter_writer"
|
|
48
|
+
require_relative "lib/links_projection"
|
|
48
49
|
require_relative "lib/restore_intent_v1"
|
|
49
50
|
|
|
50
51
|
class RestoreIntentV1CLI
|
|
@@ -116,8 +117,18 @@ class RestoreIntentV1CLI
|
|
|
116
117
|
|
|
117
118
|
other_files = prose_siblings_to_restore(dir)
|
|
118
119
|
|
|
120
|
+
# `## Links` is a purely derived section: apply_graph always re-derives new_md's body
|
|
121
|
+
# from v1_md's ORIGINAL (pre-reprojection) content, so its Links ENTRY LINES differ
|
|
122
|
+
# from the current, already-reprojected file on every single invocation, even when
|
|
123
|
+
# nothing else changed. Comparing raw new_md != current_md would treat that churn as a
|
|
124
|
+
# real change forever (write, reproject, write, reproject...), which is both pointless
|
|
125
|
+
# IO and, per D14, a false "something changed" signal. md_changed ignores only the
|
|
126
|
+
# entry lines themselves; any other difference (frontmatter graph, real prose) is
|
|
127
|
+
# still a real, detected change.
|
|
128
|
+
md_changed = differs_outside_links_entries?(new_md, current_md)
|
|
129
|
+
|
|
119
130
|
prose_changes = other_files.keys.dup
|
|
120
|
-
prose_changes.unshift("#{base}.md") if
|
|
131
|
+
prose_changes.unshift("#{base}.md") if md_changed
|
|
121
132
|
|
|
122
133
|
puts RestoreIntentV1.render_report(
|
|
123
134
|
base: base, at: at, prose_changes: prose_changes,
|
|
@@ -128,7 +139,7 @@ class RestoreIntentV1CLI
|
|
|
128
139
|
|
|
129
140
|
return unless apply
|
|
130
141
|
|
|
131
|
-
File.write(md_path, new_md) if
|
|
142
|
+
File.write(md_path, new_md) if md_changed
|
|
132
143
|
other_files.each { |f, content| File.write(File.join(dir, f), content) }
|
|
133
144
|
|
|
134
145
|
puts "Reminder: restore-to-v1 runs under the maintenance lock (PLASTIC.md > Terminal " \
|
|
@@ -136,7 +147,8 @@ class RestoreIntentV1CLI
|
|
|
136
147
|
|
|
137
148
|
handle_links_reprojection(base)
|
|
138
149
|
append_revision(dir, base, graph, files: prose_changes,
|
|
139
|
-
before_sources: current_fm["sources"], before_chain: current_fm["chain"])
|
|
150
|
+
before_sources: current_fm["sources"], before_chain: current_fm["chain"]) \
|
|
151
|
+
if md_changed || other_files.any?
|
|
140
152
|
end
|
|
141
153
|
|
|
142
154
|
private
|
|
@@ -261,6 +273,28 @@ class RestoreIntentV1CLI
|
|
|
261
273
|
"Rerun: ruby #{project_links} --plastic-home #{plastic_home}"
|
|
262
274
|
end
|
|
263
275
|
|
|
276
|
+
# True iff `md_a`/`md_b` differ OUTSIDE their ## Links ENTRY LINES (the ones
|
|
277
|
+
# LinksProjection renders/parses, plus its empty-state comment). Line-level, not
|
|
278
|
+
# section-level: a section-boundary replace (LinksSection.rewrite) would discard
|
|
279
|
+
# everything from the heading to EOF, which is unsafe here because a stray prose
|
|
280
|
+
# edit sitting after ## Links would be discarded too, not only the Links entries
|
|
281
|
+
# themselves. Frontmatter and every non-entry body line are compared verbatim, so a
|
|
282
|
+
# genuine graph or prose change is always still detected.
|
|
283
|
+
def differs_outside_links_entries?(md_a, md_b)
|
|
284
|
+
neutralize_links_entries(md_a) != neutralize_links_entries(md_b)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def neutralize_links_entries(text)
|
|
288
|
+
text.to_s.lines.map do |line|
|
|
289
|
+
stripped = line.chomp
|
|
290
|
+
if stripped.match?(LinksProjection::ENTRY_LINE_RE) || stripped == LinksProjection::EMPTY_COMMENT
|
|
291
|
+
"\n"
|
|
292
|
+
else
|
|
293
|
+
line
|
|
294
|
+
end
|
|
295
|
+
end.join
|
|
296
|
+
end
|
|
297
|
+
|
|
264
298
|
def append_revision(dir, base, graph, files:, before_sources:, before_chain:)
|
|
265
299
|
path = File.join(dir, "revisions.md")
|
|
266
300
|
existing = File.exist?(path) ? File.read(path) : "# revisions.md\n\n"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# sweep-store-worktrees (intent 178, D2) -- ONE-TIME cleanup of the store
|
|
6
|
+
# worktrees Plastic used to provision under `~/.plastic/.worktrees/` before
|
|
7
|
+
# intent 178 retired the mechanism. Dry-run by default; nothing is ever
|
|
8
|
+
# removed without an explicit --apply, run only after reviewing the dry-run
|
|
9
|
+
# report (D2: "present a dry-run for owner review before applying").
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# scripts/sweep-store-worktrees # dry run (default, safe)
|
|
13
|
+
# scripts/sweep-store-worktrees --apply # actually remove REMOVE candidates
|
|
14
|
+
# scripts/sweep-store-worktrees --home <dir> # override plastic_home (tests/CI only)
|
|
15
|
+
|
|
16
|
+
require_relative "lib/worktree_sweep"
|
|
17
|
+
|
|
18
|
+
def parse_args(argv)
|
|
19
|
+
opts = { apply: false, home: Dir.home }
|
|
20
|
+
i = 0
|
|
21
|
+
while i < argv.length
|
|
22
|
+
case argv[i]
|
|
23
|
+
when "--apply" then opts[:apply] = true
|
|
24
|
+
when "--home" then opts[:home] = argv[i += 1]
|
|
25
|
+
else
|
|
26
|
+
warn "sweep-store-worktrees: unknown argument #{argv[i].inspect}"
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
i += 1
|
|
30
|
+
end
|
|
31
|
+
opts
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def main(argv)
|
|
35
|
+
opts = parse_args(argv)
|
|
36
|
+
plastic_home = File.expand_path(File.join(opts[:home], ".plastic"))
|
|
37
|
+
unless Dir.exist?(File.join(plastic_home, ".worktrees"))
|
|
38
|
+
puts "No #{File.join(plastic_home, '.worktrees')} directory; nothing to sweep."
|
|
39
|
+
exit 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
candidates = WorktreeSweep.candidates(plastic_home: plastic_home)
|
|
43
|
+
puts WorktreeSweep.dry_run_report(candidates)
|
|
44
|
+
|
|
45
|
+
if opts[:apply]
|
|
46
|
+
removed = WorktreeSweep.apply!(candidates, plastic_home: plastic_home)
|
|
47
|
+
puts ""
|
|
48
|
+
puts "Removed #{removed.length} worktree(s):"
|
|
49
|
+
removed.each { |c| puts " #{c.name}" }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
main(ARGV) if $PROGRAM_NAME == __FILE__
|