@zalom/plastic 2.0.0-alpha.1 → 2.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/bin/lib/context_budget.rb +453 -0
  2. package/bin/plastic-bench +78 -0
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +81 -0
  5. package/hooks/savepoint +5 -5
  6. package/package.json +1 -1
  7. package/scripts/agent-report +8 -2
  8. package/scripts/append-ledger +16 -3
  9. package/scripts/dashboard.rb +39 -10
  10. package/scripts/day-summary +53 -0
  11. package/scripts/doctor.rb +163 -0
  12. package/scripts/end-intent +93 -0
  13. package/scripts/hook-capture +21 -8
  14. package/scripts/hook-close +3 -1
  15. package/scripts/hook-message-display +74 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-savepoint +45 -0
  18. package/scripts/hook-session-start +34 -1
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/arm.rb +26 -1
  21. package/scripts/lib/compact_instructions.rb +56 -0
  22. package/scripts/lib/day_summary.rb +211 -0
  23. package/scripts/lib/doctor_core.rb +52 -3
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/handoff.rb +184 -0
  26. package/scripts/lib/hook_registry.rb +14 -0
  27. package/scripts/lib/installer_core.rb +117 -11
  28. package/scripts/lib/intent_screen.rb +309 -0
  29. package/scripts/lib/intent_screen_ansi.rb +262 -0
  30. package/scripts/lib/message_display.rb +290 -0
  31. package/scripts/lib/report_screen.rb +648 -0
  32. package/scripts/lib/savepoint.rb +14 -0
  33. package/scripts/lib/screen_paint.rb +276 -0
  34. package/scripts/lib/session_close.rb +22 -2
  35. package/scripts/lib/session_git.rb +49 -18
  36. package/scripts/lib/session_ledger.rb +124 -0
  37. package/scripts/plastic-lock +8 -1
  38. package/scripts/read-config +3 -0
  39. package/scripts/report-screen +120 -0
  40. package/scripts/rollback.rb +6 -0
  41. package/scripts/savepoint-note +67 -0
  42. package/scripts/spawn-preamble +9 -2
  43. package/scripts/write-handoff +60 -0
  44. package/skills/auto/SKILL.md +13 -8
  45. package/skills/auto/references/human-report-contract.md +59 -53
  46. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  47. package/skills/intent-continuing/SKILL.md +31 -22
  48. package/skills/intent-continuing/references/boarding-matrix.md +5 -5
  49. package/skills/intent-continuing/references/context-management.md +1 -1
  50. package/skills/intent-ending/SKILL.md +8 -2
  51. package/skills/intent-executing/SKILL.md +6 -0
  52. package/templates/config.yml +5 -0
  53. package/templates/intent-screen.md +17 -0
  54. package/templates/outcome.md +14 -1
  55. package/templates/report-state.md +11 -0
@@ -0,0 +1,184 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # Handoff (intent 311): one session's hand-off, a pure rendering of its share
5
+ # of a day ledger (checklist.md and savepoint.md), written into the day
6
+ # directory as handoff--<session>.md at every tick, at PreCompact, and at
7
+ # close. Derived and regenerable: every write renders in full, so a lost or
8
+ # stale copy costs nothing. No environment reads; every path is injected.
9
+
10
+ require "fileutils"
11
+ require_relative "session_ledger"
12
+
13
+ module Handoff
14
+ module_function
15
+
16
+ TRIGGERS = %w[tick precompact close].freeze
17
+ BUDGET = 6144
18
+ OPEN_CAP = 20
19
+ DONE_CAP = 10
20
+ RECENT_CAP = 10
21
+ OTHERS_CAP = 10
22
+ # A ledger summary may run to 200 characters; a hand-off line shows the
23
+ # first 80, so the caps above are reachable inside the byte budget.
24
+ SUMMARY_MAX = 80
25
+ OPEN_STATES = %i[open pending].freeze
26
+ # Trimmed first when the budget is exceeded; Open is the last to shrink.
27
+ TRIM_ORDER = %i[others recent done open].freeze
28
+ RESUME = "Say continue; the day summary at boot and this file carry the state."
29
+
30
+ SAVEPOINT_TAIL_RE = /\A\[([^\]]*)\] \[([^\]]*)\] (.*)\z/m
31
+ private_constant :SAVEPOINT_TAIL_RE
32
+
33
+ def path_for(store, day, session)
34
+ File.join(SessionLedger.day_dir(store, day), "handoff--#{session}.md")
35
+ end
36
+
37
+ # The day this session's hand-off belongs to: the pointer's day id when
38
+ # the pointer holds one, else today (no pointer, or a pointer naming an
39
+ # intent), the same fallback SessionClose uses for the drop at close.
40
+ def day_for(store, session, today:)
41
+ path = SessionLedger.pointer_path(store, session)
42
+ return today unless File.exist?(path)
43
+
44
+ value = File.read(path).strip
45
+ SessionLedger.valid_day_id?(value) ? value : today
46
+ end
47
+
48
+ def clip(summary)
49
+ text = summary.to_s
50
+ text.length > SUMMARY_MAX ? "#{text[0, SUMMARY_MAX]}..." : text
51
+ end
52
+
53
+ # --- readers ---------------------------------------------------------------------
54
+
55
+ def read_items(store, day)
56
+ SessionLedger.read_locked(SessionLedger.checklist_path(store, day))
57
+ .each_line.filter_map { |l| SessionLedger.parse_checklist_line(l) }
58
+ end
59
+
60
+ # Parsed savepoint lines, file order: {time:, event:, session:, project:,
61
+ # summary:}. A line that does not follow the ledger grammar is skipped.
62
+ def read_savepoint(store, day)
63
+ SessionLedger.read_locked(SessionLedger.savepoint_path(store, day)).each_line.filter_map do |raw|
64
+ line = raw.chomp.scrub
65
+ next if line.empty?
66
+
67
+ time, event, rest = line.split(/\s{2,}/, 3)
68
+ next unless time && event && rest
69
+
70
+ match = SAVEPOINT_TAIL_RE.match(rest)
71
+ next unless match
72
+
73
+ { time: time, event: event, session: match[1], project: match[2], summary: match[3] }
74
+ end
75
+ end
76
+
77
+ # --- rendering, pure -----------------------------------------------------------
78
+
79
+ def render(store:, day:, session:, trigger:, now: Time.now)
80
+ raise ArgumentError, "unknown trigger: #{trigger.inspect}" unless TRIGGERS.include?(trigger)
81
+
82
+ items = read_items(store, day)
83
+ mine = items.select { |i| i[:session] == session }
84
+ lists = {
85
+ open: mine.select { |i| OPEN_STATES.include?(i[:state]) }.map { |i| item_line(i) },
86
+ done: mine.select { |i| i[:state] == :done }.map { |i| item_line(i) },
87
+ recent: read_savepoint(store, day).select { |e| e[:session] == session }.map { |e| recent_line(e) },
88
+ others: others_lines(items, session),
89
+ }
90
+ hidden = Hash.new(0)
91
+ cap!(lists, hidden, :open, OPEN_CAP)
92
+ cap!(lists, hidden, :done, DONE_CAP)
93
+ cap!(lists, hidden, :recent, RECENT_CAP)
94
+ if lists[:others].size > OTHERS_CAP
95
+ hidden[:others] += lists[:others].size - OTHERS_CAP
96
+ lists[:others] = lists[:others].first(OTHERS_CAP)
97
+ end
98
+
99
+ header = [
100
+ "# Hand-off: session #{session}, #{day}",
101
+ "",
102
+ "Written #{now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')} at #{trigger}",
103
+ "",
104
+ ]
105
+ loop do
106
+ text = compose(header, lists, hidden)
107
+ return text if text.bytesize <= BUDGET
108
+
109
+ key = TRIM_ORDER.find { |k| !lists[k].empty? }
110
+ return text unless key
111
+
112
+ # Open, Done, and Recent keep their newest entries; Others has no order.
113
+ key == :others ? lists[key].pop : lists[key].shift
114
+ hidden[key] += 1
115
+ end
116
+ end
117
+
118
+ # Keeps the newest `cap` lines (the file is chronological) and counts the rest.
119
+ def cap!(lists, hidden, key, cap)
120
+ return unless lists[key].size > cap
121
+
122
+ hidden[key] += lists[key].size - cap
123
+ lists[key] = lists[key].last(cap)
124
+ end
125
+
126
+ def compose(header, lists, hidden)
127
+ sections = [
128
+ section("Open", lists[:open], hidden[:open]),
129
+ section("Done", lists[:done], hidden[:done]),
130
+ section("Recent", lists[:recent], hidden[:recent]),
131
+ section("Others today", lists[:others], hidden[:others]),
132
+ "## Resume\n#{RESUME}\n",
133
+ ]
134
+ (header + sections.compact).join("\n")
135
+ end
136
+
137
+ def section(title, lines, hidden)
138
+ return nil if lines.empty? && hidden.zero?
139
+
140
+ body = lines.dup
141
+ body << "(+#{hidden} more)" if hidden.positive?
142
+ "## #{title}\n#{body.join("\n")}\n"
143
+ end
144
+
145
+ def item_line(item)
146
+ "- [#{item[:project]}] #{clip(item[:summary])}"
147
+ end
148
+
149
+ def recent_line(event)
150
+ "- #{event[:time][11, 5]}Z #{event[:event]} #{clip(event[:summary])}"
151
+ end
152
+
153
+ def others_lines(items, session)
154
+ items.reject { |i| i[:session] == session }
155
+ .group_by { |i| i[:session] }
156
+ .sort
157
+ .map do |sid, theirs|
158
+ open = theirs.count { |i| OPEN_STATES.include?(i[:state]) }
159
+ done = theirs.count { |i| i[:state] == :done }
160
+ "- #{sid}: #{open} open, #{done} done"
161
+ end
162
+ end
163
+
164
+ # --- writing -------------------------------------------------------------------
165
+
166
+ # Opens the day first (a tick after midnight never fails), renders, and
167
+ # writes through a per-process temp file and rename, so a crash leaves no
168
+ # partial hand-off and two writers for one session (a tick overlapping a
169
+ # PreCompact) never share a temp name. Returns the path. With
170
+ # `templates: nil` the day is not scaffolded, only its directory ensured.
171
+ def write(store:, day:, session:, trigger:, templates:, now: Time.now)
172
+ if templates
173
+ SessionLedger.open_day(store: store, day: day, templates: templates, author: session)
174
+ else
175
+ FileUtils.mkdir_p(SessionLedger.day_dir(store, day))
176
+ end
177
+ text = render(store: store, day: day, session: session, trigger: trigger, now: now)
178
+ target = path_for(store, day, session)
179
+ tmp = File.join(File.dirname(target), ".handoff-#{session}-#{Process.pid}-#{Thread.current.object_id}.tmp")
180
+ File.write(tmp, text)
181
+ File.rename(tmp, target)
182
+ target
183
+ end
184
+ end
@@ -54,6 +54,20 @@ module HookRegistry
54
54
  { "name" => "capture", "status" => "Capturing prompt into the session ledger..." },
55
55
  ] },
56
56
  ],
57
+ # This entry belongs to the Claude adapter half of Plastic's
58
+ # harness-agnostic-core / Claude-adapter split (intent 316a1, D3
59
+ # supersedes 316a's D6): MessageDisplay is not one of
60
+ # CODEX_LIVE_STATE_EVENTS, so codex_hooks_json (below) never picks it
61
+ # up; codex_hook_names stays exactly what it was (pinned by
62
+ # test/hook_registry_test.rb:82 and :110-111). Fires on every streamed
63
+ # chunk of every assistant message (D11); the launcher (hooks/message-
64
+ # display) decides with shell builtins and forks nothing on the common
65
+ # case, execing Ruby only for a candidate message.
66
+ "MessageDisplay" => [
67
+ { "matcher" => "", "hooks" => [
68
+ { "name" => "message-display", "status" => "" },
69
+ ] },
70
+ ],
57
71
  }
58
72
  end
59
73
 
@@ -9,6 +9,7 @@ require "time"
9
9
  require_relative "hook_registry"
10
10
  require_relative "agent_models"
11
11
  require_relative "harness_text"
12
+ require_relative "compact_instructions"
12
13
 
13
14
  # Shared installer machinery, instantiable with injected package root / store / agent
14
15
  # map so the verb scripts (install/update/uninstall/rollback) and their tests can run
@@ -33,6 +34,15 @@ class InstallerCore
33
34
  # Regex matching exactly one managed section (BEGIN line .. END line), non-greedy.
34
35
  CODEX_SECTION_RE = /^<!-- BEGIN PLASTIC INTEGRATION.*?-->\n.*?\n<!-- END PLASTIC INTEGRATION -->\n?/m
35
36
 
37
+ # Claude CLAUDE.md marked-section markers (intent 312). A pair of its own, not the
38
+ # Codex literals: the two managed files can be one file (a user who symlinks
39
+ # ~/.claude/CLAUDE.md at ~/.codex/AGENTS.md, or the reverse), and a shared literal
40
+ # would let one body silently replace the other and an uninstall of one strip both.
41
+ # doctor_core.rb matches these literals structurally, so keep the two in sync by hand.
42
+ CLAUDE_SECTION_BEGIN_PREFIX = "<!-- BEGIN PLASTIC COMPACT"
43
+ CLAUDE_SECTION_END = "<!-- END PLASTIC COMPACT -->"
44
+ CLAUDE_SECTION_RE = /^<!-- BEGIN PLASTIC COMPACT.*?-->\n.*?\n<!-- END PLASTIC COMPACT -->\n?/m
45
+
36
46
  # Curated essentials plus a pointer to ~/.plastic/PLASTIC.md and the plastic-conventions
37
47
  # skill, injected into ~/.codex/AGENTS.md. Not a slice of PLASTIC.md itself: AGENTS.md is
38
48
  # a shared file Codex merges from multiple sources, so this block stays a small,
@@ -66,8 +76,25 @@ class InstallerCore
66
76
  @agents = agents
67
77
  end
68
78
 
79
+ # Reads the package version from `package.json` when present (the source
80
+ # tree, and any dev checkout), falling back to the bare `VERSION` file the
81
+ # installer copies to `~/.plastic` (row B, spec 315b): an installed system
82
+ # carries VERSION but never package.json, so `rollback.rb` and `update.rb`
83
+ # constructing an InstallerCore with `package_root: ~/.plastic` crashed with
84
+ # a raw Errno::ENOENT before this fallback existed. Raises a named error,
85
+ # not a bare ENOENT, when neither file exists, so the caller is not
86
+ # misdirected toward the wrong missing file.
69
87
  def read_package_version(root)
70
- File.read(File.join(root, "package.json")).then { |s| JSON.parse(s)["version"] }
88
+ package_json = File.join(root, "package.json")
89
+ version_file = File.join(root, "VERSION")
90
+
91
+ if File.exist?(package_json)
92
+ JSON.parse(File.read(package_json))["version"]
93
+ elsif File.exist?(version_file)
94
+ File.read(version_file).strip
95
+ else
96
+ raise "cannot determine the package version: neither #{package_json} nor #{version_file} exists"
97
+ end
71
98
  end
72
99
 
73
100
  # --- Channel derivation (the channel is encoded in the version string) ---
@@ -336,6 +363,7 @@ class InstallerCore
336
363
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
337
364
  "scripts/plastic-lock" => "scripts/plastic-lock",
338
365
  "scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
366
+ "scripts/lib/compact_instructions.rb" => "scripts/lib/compact_instructions.rb",
339
367
  "scripts/agent-report" => "scripts/agent-report",
340
368
  "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
341
369
  "scripts/insight-append" => "scripts/insight-append",
@@ -372,6 +400,9 @@ class InstallerCore
372
400
  "scripts/lib/harness_text.rb" => "scripts/lib/harness_text.rb",
373
401
  "scripts/codex-hook" => "scripts/codex-hook",
374
402
  "scripts/spawn-preamble" => "scripts/spawn-preamble",
403
+ "scripts/lib/report_screen.rb" => "scripts/lib/report_screen.rb",
404
+ "scripts/report-screen" => "scripts/report-screen",
405
+ "scripts/savepoint-note" => "scripts/savepoint-note",
375
406
  "scripts/lib/store_provisioning.rb" => "scripts/lib/store_provisioning.rb",
376
407
  "scripts/provision-project-store" => "scripts/provision-project-store",
377
408
  "scripts/lib/project_validator.rb" => "scripts/lib/project_validator.rb",
@@ -408,6 +439,22 @@ class InstallerCore
408
439
  "scripts/lib/session_backfill.rb" => "scripts/lib/session_backfill.rb",
409
440
  "scripts/lib/session_close.rb" => "scripts/lib/session_close.rb",
410
441
  "scripts/lib/session_git.rb" => "scripts/lib/session_git.rb",
442
+ "scripts/lib/handoff.rb" => "scripts/lib/handoff.rb",
443
+ "scripts/lib/day_summary.rb" => "scripts/lib/day_summary.rb",
444
+ "scripts/write-handoff" => "scripts/write-handoff",
445
+ "scripts/day-summary" => "scripts/day-summary",
446
+ "scripts/lib/intent_screen.rb" => "scripts/lib/intent_screen.rb",
447
+ "scripts/intent-screen" => "scripts/intent-screen",
448
+ "scripts/hook-savepoint" => "scripts/hook-savepoint",
449
+ # Intent 316a: hooks/* only glob-copies scripts/*, never scripts/lib/*
450
+ # (see hook_files above), so the two lib files a require_relative
451
+ # between themselves are unguarded there — these three literal
452
+ # entries are their only protection (test/install_sync_test.rb:23-29
453
+ # greps installer_core.rb's own source text for "scripts/<name>").
454
+ "scripts/lib/intent_screen_ansi.rb" => "scripts/lib/intent_screen_ansi.rb",
455
+ "scripts/lib/screen_paint.rb" => "scripts/lib/screen_paint.rb",
456
+ "scripts/lib/message_display.rb" => "scripts/lib/message_display.rb",
457
+ "scripts/hook-message-display" => "scripts/hook-message-display",
411
458
  }
412
459
  end
413
460
 
@@ -421,6 +468,8 @@ class InstallerCore
421
468
  version: 3
422
469
  execution_mode: subagent-driven
423
470
  stale_threshold_days: 3
471
+ context_offer_tokens: 350000
472
+ context_insist_tokens: 500000
424
473
  hash_length: 6
425
474
  hash_algorithm: sha256-base36
426
475
  max_slug_words: 5
@@ -779,6 +828,11 @@ class InstallerCore
779
828
  choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
780
829
  merge_claude_hooks(settings_path, choice: choice)
781
830
 
831
+ # Instruction injection (intent 312): the compact-instructions block into
832
+ # ~/.claude/CLAUDE.md. A partial-ownership user file, so it is NOT manifest-tracked
833
+ # (stripped surgically on uninstall), the same treatment ~/.codex/AGENTS.md gets.
834
+ inject_claude_compact_md(File.join(config[:dir], "CLAUDE.md"))
835
+
782
836
  # Write manifest
783
837
  manifest_path = File.join(plastic_dir, "manifest.json")
784
838
  write_manifest(installed, manifest_path)
@@ -1344,14 +1398,28 @@ class InstallerCore
1344
1398
  raise e
1345
1399
  end
1346
1400
 
1347
- def codex_section(body: CODEX_AGENTS_MD_BODY)
1401
+ # A managed instruction file may be a symlink into a dotfiles repo. write_text_atomic
1402
+ # renames a temp file over its argument, which would replace the link with a regular
1403
+ # file and silently detach it, so every read and write resolves the link first and the
1404
+ # change lands on its target (intent 312).
1405
+ def resolve_managed_path(path)
1406
+ File.symlink?(path) ? File.realpath(path) : path
1407
+ rescue Errno::ENOENT
1408
+ path
1409
+ end
1410
+
1411
+ def marked_section(body: CODEX_AGENTS_MD_BODY, begin_prefix: CODEX_SECTION_BEGIN_PREFIX,
1412
+ end_marker: CODEX_SECTION_END)
1348
1413
  hash = Digest::SHA256.hexdigest(body)[0, 12]
1349
- "#{CODEX_SECTION_BEGIN_PREFIX} hash:#{hash} -->\n#{body.strip}\n#{CODEX_SECTION_END}\n"
1414
+ "#{begin_prefix} hash:#{hash} -->\n#{body.strip}\n#{end_marker}\n"
1350
1415
  end
1351
1416
 
1352
1417
  # Returns :created / :appended / :replaced / :refused. Never raises on a normal user file.
1353
- def inject_codex_agents_md(path, body: CODEX_AGENTS_MD_BODY)
1354
- section = codex_section(body: body)
1418
+ def inject_marked_section(path, body: CODEX_AGENTS_MD_BODY,
1419
+ begin_prefix: CODEX_SECTION_BEGIN_PREFIX,
1420
+ end_marker: CODEX_SECTION_END, section_re: CODEX_SECTION_RE)
1421
+ section = marked_section(body: body, begin_prefix: begin_prefix, end_marker: end_marker)
1422
+ path = resolve_managed_path(path)
1355
1423
 
1356
1424
  unless File.exist?(path)
1357
1425
  FileUtils.mkdir_p(File.dirname(path))
@@ -1360,14 +1428,14 @@ class InstallerCore
1360
1428
  end
1361
1429
 
1362
1430
  content = File.read(path)
1363
- has_begin = content.include?(CODEX_SECTION_BEGIN_PREFIX)
1364
- has_end = content.include?(CODEX_SECTION_END)
1431
+ has_begin = content.include?(begin_prefix)
1432
+ has_end = content.include?(end_marker)
1365
1433
 
1366
1434
  # 22a safety rule: never write if the existing section cannot be parsed.
1367
1435
  return :refused if has_begin && !has_end
1368
1436
 
1369
1437
  if has_begin
1370
- write_text_atomic(path, content.sub(CODEX_SECTION_RE, section))
1438
+ write_text_atomic(path, content.sub(section_re, section))
1371
1439
  :replaced
1372
1440
  else
1373
1441
  base = content.end_with?("\n") ? content : content + "\n"
@@ -1376,18 +1444,41 @@ class InstallerCore
1376
1444
  end
1377
1445
  end
1378
1446
 
1447
+ # --- The two blocks Plastic ships, each with its own marker pair ---
1448
+
1449
+ def codex_section(body: CODEX_AGENTS_MD_BODY)
1450
+ marked_section(body: body)
1451
+ end
1452
+
1453
+ def inject_codex_agents_md(path, body: CODEX_AGENTS_MD_BODY)
1454
+ inject_marked_section(path, body: body)
1455
+ end
1456
+
1457
+ # The compact-instructions block for ~/.claude/CLAUDE.md (intent 312).
1458
+ def claude_compact_section(body: CompactInstructions::BODY)
1459
+ marked_section(body: body, begin_prefix: CLAUDE_SECTION_BEGIN_PREFIX,
1460
+ end_marker: CLAUDE_SECTION_END)
1461
+ end
1462
+
1463
+ def inject_claude_compact_md(path, body: CompactInstructions::BODY)
1464
+ inject_marked_section(path, body: body, begin_prefix: CLAUDE_SECTION_BEGIN_PREFIX,
1465
+ end_marker: CLAUDE_SECTION_END, section_re: CLAUDE_SECTION_RE)
1466
+ end
1467
+
1379
1468
  # Remove exactly Plastic's managed section from a user-owned AGENTS.md. Preserve all other
1380
1469
  # content. Delete the file only if Plastic created it and nothing else remains. Returns the
1381
1470
  # path when it acted, nil on no-op. Mirrors remove_claude_hooks: dedicated surgical strip,
1382
1471
  # never the manifest whole-file-delete path.
1383
- def strip_codex_section(path)
1472
+ def strip_marked_section(path, begin_prefix: CODEX_SECTION_BEGIN_PREFIX,
1473
+ section_re: CODEX_SECTION_RE)
1474
+ path = resolve_managed_path(path)
1384
1475
  return nil unless File.exist?(path)
1385
1476
  content = File.read(path)
1386
- return nil unless content.include?(CODEX_SECTION_BEGIN_PREFIX)
1477
+ return nil unless content.include?(begin_prefix)
1387
1478
 
1388
1479
  # Remove the section plus the single separator newline the append introduced, so a
1389
1480
  # standard user file round-trips byte-identical.
1390
- stripped = content.sub(/\n?#{CODEX_SECTION_RE}/, "")
1481
+ stripped = content.sub(/\n?#{section_re}/, "")
1391
1482
 
1392
1483
  if stripped.strip.empty?
1393
1484
  File.delete(path) # Plastic-created file: nothing else left
@@ -1398,6 +1489,15 @@ class InstallerCore
1398
1489
  path
1399
1490
  end
1400
1491
 
1492
+ def strip_codex_section(path)
1493
+ strip_marked_section(path)
1494
+ end
1495
+
1496
+ def strip_claude_compact_section(path)
1497
+ strip_marked_section(path, begin_prefix: CLAUDE_SECTION_BEGIN_PREFIX,
1498
+ section_re: CLAUDE_SECTION_RE)
1499
+ end
1500
+
1401
1501
  # --- Uninstall ---
1402
1502
 
1403
1503
  def handle_uninstall(uninstall_agents)
@@ -1424,6 +1524,7 @@ class InstallerCore
1424
1524
  puts " ls ~/.claude/skills | grep '^plastic-' # → no output"
1425
1525
  puts " ls ~/.claude/hooks | grep '^plastic-' # → no output"
1426
1526
  puts " grep -c plastic ~/.claude/settings.json # → only hook refs gone"
1527
+ puts " grep 'PLASTIC COMPACT' ~/.claude/CLAUDE.md # → no output"
1427
1528
  puts "\n To also delete your intent store: rm -rf #{tilde(plastic_home)}\n\n"
1428
1529
  end
1429
1530
 
@@ -1470,6 +1571,11 @@ class InstallerCore
1470
1571
  settings_path = File.join(config[:dir], "settings.json")
1471
1572
  remove_claude_hooks(settings_path) if File.exist?(settings_path)
1472
1573
  removed.concat(migrate_legacy_plugin(config[:dir]))
1574
+
1575
+ # The compact-instructions block in the user-owned CLAUDE.md (intent 312): a
1576
+ # surgical strip, never the manifest whole-file-delete path above.
1577
+ stripped = strip_claude_compact_section(File.join(config[:dir], "CLAUDE.md"))
1578
+ removed << stripped if stripped
1473
1579
  end
1474
1580
 
1475
1581
  # Codex: surgically strip Plastic's marked section from the user-owned AGENTS.md