@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.
- package/bin/lib/context_budget.rb +453 -0
- package/bin/plastic-bench +78 -0
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +81 -0
- package/hooks/savepoint +5 -5
- package/package.json +1 -1
- package/scripts/agent-report +8 -2
- package/scripts/append-ledger +16 -3
- package/scripts/dashboard.rb +39 -10
- package/scripts/day-summary +53 -0
- package/scripts/doctor.rb +163 -0
- package/scripts/end-intent +93 -0
- package/scripts/hook-capture +21 -8
- package/scripts/hook-close +3 -1
- package/scripts/hook-message-display +74 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-savepoint +45 -0
- package/scripts/hook-session-start +34 -1
- package/scripts/intent-screen +77 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/compact_instructions.rb +56 -0
- package/scripts/lib/day_summary.rb +211 -0
- package/scripts/lib/doctor_core.rb +52 -3
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/handoff.rb +184 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/installer_core.rb +117 -11
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +290 -0
- package/scripts/lib/report_screen.rb +648 -0
- package/scripts/lib/savepoint.rb +14 -0
- package/scripts/lib/screen_paint.rb +276 -0
- package/scripts/lib/session_close.rb +22 -2
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +124 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -0
- package/scripts/report-screen +120 -0
- package/scripts/rollback.rb +6 -0
- package/scripts/savepoint-note +67 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/write-handoff +60 -0
- package/skills/auto/SKILL.md +13 -8
- package/skills/auto/references/human-report-contract.md +59 -53
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/intent-continuing/SKILL.md +31 -22
- package/skills/intent-continuing/references/boarding-matrix.md +5 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +8 -2
- package/skills/intent-executing/SKILL.md +6 -0
- package/templates/config.yml +5 -0
- package/templates/intent-screen.md +17 -0
- package/templates/outcome.md +14 -1
- 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.
|
|
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
|
-
|
|
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
|
-
"#{
|
|
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
|
|
1354
|
-
|
|
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?(
|
|
1364
|
-
has_end = content.include?(
|
|
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(
|
|
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
|
|
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?(
|
|
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?#{
|
|
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
|