@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.
Files changed (44) hide show
  1. package/PLASTIC-reference.md +2 -0
  2. package/PLASTIC.md +160 -42
  3. package/agents/plastic-intent-curator.md +10 -2
  4. package/package.json +1 -1
  5. package/scripts/codex-hook +122 -8
  6. package/scripts/dashboard.rb +323 -71
  7. package/scripts/doctor.rb +271 -15
  8. package/scripts/end-intent +32 -7
  9. package/scripts/hook-lock-gate +8 -3
  10. package/scripts/install.rb +51 -6
  11. package/scripts/lib/bridge.rb +79 -30
  12. package/scripts/lib/hook_registry.rb +44 -2
  13. package/scripts/lib/installer_core.rb +45 -6
  14. package/scripts/lib/lock.rb +186 -11
  15. package/scripts/lib/maintenance_git.rb +94 -0
  16. package/scripts/lib/revisions_writer.rb +69 -0
  17. package/scripts/lib/worktree.rb +14 -32
  18. package/scripts/lib/worktree_sweep.rb +129 -0
  19. package/scripts/maintenance-run +236 -0
  20. package/scripts/plastic-lock +76 -9
  21. package/scripts/project-links +127 -24
  22. package/scripts/rebuild-graph +37 -3
  23. package/scripts/restore-intent-v1 +37 -3
  24. package/scripts/sweep-store-worktrees +53 -0
  25. package/skills/auto/SKILL.md +29 -6
  26. package/skills/auto/references/agent-architecture.md +7 -0
  27. package/skills/auto/references/end-tail.md +8 -6
  28. package/skills/dashboard/SKILL.md +48 -25
  29. package/skills/dashboard/evals/evals.json +4 -4
  30. package/skills/dashboard/templates/dashboard-global.md +3 -5
  31. package/skills/dashboard/templates/dashboard-project.md +6 -18
  32. package/skills/doctor/SKILL.md +6 -0
  33. package/skills/intent-locking/SKILL.md +20 -2
  34. package/skills/intent-starting/SKILL.md +6 -4
  35. package/skills/project-continuing/SKILL.md +10 -0
  36. package/skills/project-continuing/evals/evals.json +3 -3
  37. package/skills/project-continuing/references/board-fill.md +13 -11
  38. package/skills/releasing/SKILL.md +3 -3
  39. package/skills/store-curating/SKILL.md +9 -0
  40. package/skills/store-curating/evals/evals.json +16 -0
  41. package/skills/tutorial/SKILL.md +4 -4
  42. package/skills/tutorial/references/track-1-guided.md +2 -1
  43. package/skills/tutorial/references/track-2-auto.md +2 -1
  44. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -1
@@ -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
- ok = Lock.add_delegate(dir, delegate: opts[:delegate], session: key)
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
- puts "delegated #{opts[:delegate]} under #{key}"
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,
@@ -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 silently, exactly as before: it names no real relationship to
22
- # lose.
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
- results[key] = project_store(
137
- key, nodes_by_store[key],
138
- relocation_map: relocation_map, store_index: store_index, node_index: node_index
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
- orphans_preserved: kept, orphans_dropped_optin: dropped_optin }
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
- File.write(node[:path], updated) unless dry_run
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 silently, exactly as before
199
- # intent 192 (this is dealintell 3b's three broken single-dash lines:
200
- # garbage, not data). Returns [kept_entries, dropped_optin_entries], both
201
- # [{target:, label:}].
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
- next if label.nil? # dead: not backed AND does not resolve; silent drop, as before
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 | --drop-unbacked-links)"
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] }]
@@ -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
- File.write(original[:path], updated) if updated != content
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 new_md != current_md
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 new_md != current_md
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__