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

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.
@@ -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,
@@ -336,6 +346,7 @@ class InstallerCore
336
346
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
337
347
  "scripts/plastic-lock" => "scripts/plastic-lock",
338
348
  "scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
349
+ "scripts/lib/compact_instructions.rb" => "scripts/lib/compact_instructions.rb",
339
350
  "scripts/agent-report" => "scripts/agent-report",
340
351
  "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
341
352
  "scripts/insight-append" => "scripts/insight-append",
@@ -408,6 +419,11 @@ class InstallerCore
408
419
  "scripts/lib/session_backfill.rb" => "scripts/lib/session_backfill.rb",
409
420
  "scripts/lib/session_close.rb" => "scripts/lib/session_close.rb",
410
421
  "scripts/lib/session_git.rb" => "scripts/lib/session_git.rb",
422
+ "scripts/lib/handoff.rb" => "scripts/lib/handoff.rb",
423
+ "scripts/lib/day_summary.rb" => "scripts/lib/day_summary.rb",
424
+ "scripts/write-handoff" => "scripts/write-handoff",
425
+ "scripts/day-summary" => "scripts/day-summary",
426
+ "scripts/hook-savepoint" => "scripts/hook-savepoint",
411
427
  }
412
428
  end
413
429
 
@@ -421,6 +437,8 @@ class InstallerCore
421
437
  version: 3
422
438
  execution_mode: subagent-driven
423
439
  stale_threshold_days: 3
440
+ context_offer_tokens: 350000
441
+ context_insist_tokens: 500000
424
442
  hash_length: 6
425
443
  hash_algorithm: sha256-base36
426
444
  max_slug_words: 5
@@ -779,6 +797,11 @@ class InstallerCore
779
797
  choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
780
798
  merge_claude_hooks(settings_path, choice: choice)
781
799
 
800
+ # Instruction injection (intent 312): the compact-instructions block into
801
+ # ~/.claude/CLAUDE.md. A partial-ownership user file, so it is NOT manifest-tracked
802
+ # (stripped surgically on uninstall), the same treatment ~/.codex/AGENTS.md gets.
803
+ inject_claude_compact_md(File.join(config[:dir], "CLAUDE.md"))
804
+
782
805
  # Write manifest
783
806
  manifest_path = File.join(plastic_dir, "manifest.json")
784
807
  write_manifest(installed, manifest_path)
@@ -1344,14 +1367,28 @@ class InstallerCore
1344
1367
  raise e
1345
1368
  end
1346
1369
 
1347
- def codex_section(body: CODEX_AGENTS_MD_BODY)
1370
+ # A managed instruction file may be a symlink into a dotfiles repo. write_text_atomic
1371
+ # renames a temp file over its argument, which would replace the link with a regular
1372
+ # file and silently detach it, so every read and write resolves the link first and the
1373
+ # change lands on its target (intent 312).
1374
+ def resolve_managed_path(path)
1375
+ File.symlink?(path) ? File.realpath(path) : path
1376
+ rescue Errno::ENOENT
1377
+ path
1378
+ end
1379
+
1380
+ def marked_section(body: CODEX_AGENTS_MD_BODY, begin_prefix: CODEX_SECTION_BEGIN_PREFIX,
1381
+ end_marker: CODEX_SECTION_END)
1348
1382
  hash = Digest::SHA256.hexdigest(body)[0, 12]
1349
- "#{CODEX_SECTION_BEGIN_PREFIX} hash:#{hash} -->\n#{body.strip}\n#{CODEX_SECTION_END}\n"
1383
+ "#{begin_prefix} hash:#{hash} -->\n#{body.strip}\n#{end_marker}\n"
1350
1384
  end
1351
1385
 
1352
1386
  # 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)
1387
+ def inject_marked_section(path, body: CODEX_AGENTS_MD_BODY,
1388
+ begin_prefix: CODEX_SECTION_BEGIN_PREFIX,
1389
+ end_marker: CODEX_SECTION_END, section_re: CODEX_SECTION_RE)
1390
+ section = marked_section(body: body, begin_prefix: begin_prefix, end_marker: end_marker)
1391
+ path = resolve_managed_path(path)
1355
1392
 
1356
1393
  unless File.exist?(path)
1357
1394
  FileUtils.mkdir_p(File.dirname(path))
@@ -1360,14 +1397,14 @@ class InstallerCore
1360
1397
  end
1361
1398
 
1362
1399
  content = File.read(path)
1363
- has_begin = content.include?(CODEX_SECTION_BEGIN_PREFIX)
1364
- has_end = content.include?(CODEX_SECTION_END)
1400
+ has_begin = content.include?(begin_prefix)
1401
+ has_end = content.include?(end_marker)
1365
1402
 
1366
1403
  # 22a safety rule: never write if the existing section cannot be parsed.
1367
1404
  return :refused if has_begin && !has_end
1368
1405
 
1369
1406
  if has_begin
1370
- write_text_atomic(path, content.sub(CODEX_SECTION_RE, section))
1407
+ write_text_atomic(path, content.sub(section_re, section))
1371
1408
  :replaced
1372
1409
  else
1373
1410
  base = content.end_with?("\n") ? content : content + "\n"
@@ -1376,18 +1413,41 @@ class InstallerCore
1376
1413
  end
1377
1414
  end
1378
1415
 
1416
+ # --- The two blocks Plastic ships, each with its own marker pair ---
1417
+
1418
+ def codex_section(body: CODEX_AGENTS_MD_BODY)
1419
+ marked_section(body: body)
1420
+ end
1421
+
1422
+ def inject_codex_agents_md(path, body: CODEX_AGENTS_MD_BODY)
1423
+ inject_marked_section(path, body: body)
1424
+ end
1425
+
1426
+ # The compact-instructions block for ~/.claude/CLAUDE.md (intent 312).
1427
+ def claude_compact_section(body: CompactInstructions::BODY)
1428
+ marked_section(body: body, begin_prefix: CLAUDE_SECTION_BEGIN_PREFIX,
1429
+ end_marker: CLAUDE_SECTION_END)
1430
+ end
1431
+
1432
+ def inject_claude_compact_md(path, body: CompactInstructions::BODY)
1433
+ inject_marked_section(path, body: body, begin_prefix: CLAUDE_SECTION_BEGIN_PREFIX,
1434
+ end_marker: CLAUDE_SECTION_END, section_re: CLAUDE_SECTION_RE)
1435
+ end
1436
+
1379
1437
  # Remove exactly Plastic's managed section from a user-owned AGENTS.md. Preserve all other
1380
1438
  # content. Delete the file only if Plastic created it and nothing else remains. Returns the
1381
1439
  # path when it acted, nil on no-op. Mirrors remove_claude_hooks: dedicated surgical strip,
1382
1440
  # never the manifest whole-file-delete path.
1383
- def strip_codex_section(path)
1441
+ def strip_marked_section(path, begin_prefix: CODEX_SECTION_BEGIN_PREFIX,
1442
+ section_re: CODEX_SECTION_RE)
1443
+ path = resolve_managed_path(path)
1384
1444
  return nil unless File.exist?(path)
1385
1445
  content = File.read(path)
1386
- return nil unless content.include?(CODEX_SECTION_BEGIN_PREFIX)
1446
+ return nil unless content.include?(begin_prefix)
1387
1447
 
1388
1448
  # Remove the section plus the single separator newline the append introduced, so a
1389
1449
  # standard user file round-trips byte-identical.
1390
- stripped = content.sub(/\n?#{CODEX_SECTION_RE}/, "")
1450
+ stripped = content.sub(/\n?#{section_re}/, "")
1391
1451
 
1392
1452
  if stripped.strip.empty?
1393
1453
  File.delete(path) # Plastic-created file: nothing else left
@@ -1398,6 +1458,15 @@ class InstallerCore
1398
1458
  path
1399
1459
  end
1400
1460
 
1461
+ def strip_codex_section(path)
1462
+ strip_marked_section(path)
1463
+ end
1464
+
1465
+ def strip_claude_compact_section(path)
1466
+ strip_marked_section(path, begin_prefix: CLAUDE_SECTION_BEGIN_PREFIX,
1467
+ section_re: CLAUDE_SECTION_RE)
1468
+ end
1469
+
1401
1470
  # --- Uninstall ---
1402
1471
 
1403
1472
  def handle_uninstall(uninstall_agents)
@@ -1424,6 +1493,7 @@ class InstallerCore
1424
1493
  puts " ls ~/.claude/skills | grep '^plastic-' # → no output"
1425
1494
  puts " ls ~/.claude/hooks | grep '^plastic-' # → no output"
1426
1495
  puts " grep -c plastic ~/.claude/settings.json # → only hook refs gone"
1496
+ puts " grep 'PLASTIC COMPACT' ~/.claude/CLAUDE.md # → no output"
1427
1497
  puts "\n To also delete your intent store: rm -rf #{tilde(plastic_home)}\n\n"
1428
1498
  end
1429
1499
 
@@ -1470,6 +1540,11 @@ class InstallerCore
1470
1540
  settings_path = File.join(config[:dir], "settings.json")
1471
1541
  remove_claude_hooks(settings_path) if File.exist?(settings_path)
1472
1542
  removed.concat(migrate_legacy_plugin(config[:dir]))
1543
+
1544
+ # The compact-instructions block in the user-owned CLAUDE.md (intent 312): a
1545
+ # surgical strip, never the manifest whole-file-delete path above.
1546
+ stripped = strip_claude_compact_section(File.join(config[:dir], "CLAUDE.md"))
1547
+ removed << stripped if stripped
1473
1548
  end
1474
1549
 
1475
1550
  # Codex: surgically strip Plastic's marked section from the user-owned AGENTS.md
@@ -9,12 +9,22 @@
9
9
 
10
10
  require "fileutils"
11
11
  require_relative "session_ledger"
12
+ require_relative "handoff"
12
13
 
13
14
  module SessionClose
14
15
  module_function
15
16
 
16
17
  NOOP_REASONS = %w[clear resume].freeze
17
18
 
19
+ # The default hand-off writer (intent 311, spec D6): renders this session's
20
+ # share of the pointer day into handoff--<session>.md before the tmp dir
21
+ # goes. The hook script builds it with the shipped templates dir.
22
+ def default_handoff(templates)
23
+ lambda do |store, day, session|
24
+ Handoff.write(store: store, day: day, session: session, trigger: "close", templates: templates)
25
+ end
26
+ end
27
+
18
28
  # The default spawner starts the day filer detached so a slow filing never
19
29
  # blocks the harness shutdown (Codex kills a SessionEnd hook after 3 s).
20
30
  def default_spawner(script)
@@ -26,8 +36,8 @@ module SessionClose
26
36
  end
27
37
 
28
38
  # Returns a small report hash; never raises.
29
- def run(payload:, store:, today:, spawner:, now: Time.now)
30
- report = { reason: nil, dropped: 0, removed_tmp: false, spawned: nil }
39
+ def run(payload:, store:, today:, spawner:, handoff: nil, now: Time.now)
40
+ report = { reason: nil, dropped: 0, removed_tmp: false, spawned: nil, handoff: false }
31
41
  reason = payload.is_a?(Hash) ? payload["reason"].to_s : ""
32
42
  report[:reason] = reason
33
43
  return report if NOOP_REASONS.include?(reason)
@@ -49,6 +59,16 @@ module SessionClose
49
59
  count
50
60
  end || 0
51
61
 
62
+ # The hand-off (intent 311, spec D6) is written after the drop, so it
63
+ # reflects it, and before the tmp dir goes, since the pointer lives there.
64
+ # An intent pointer falls back to today, the same as the drop above.
65
+ if handoff
66
+ report[:handoff] = safely do
67
+ handoff.call(store, pointer_day, session)
68
+ true
69
+ end || false
70
+ end
71
+
52
72
  report[:removed_tmp] = safely do
53
73
  dir = SessionLedger.session_tmp_dir(store, session)
54
74
  FileUtils.rm_rf(dir) if Dir.exist?(dir)
@@ -14,6 +14,9 @@ require_relative "lib/agent_models"
14
14
  DEFAULTS = {
15
15
  "version" => 3,
16
16
  "stale_threshold_days" => 3,
17
+ # Absolute token counts for a 1M window, 35 and 50 percent (intent 312, 296 D38).
18
+ "context_offer_tokens" => 350_000,
19
+ "context_insist_tokens" => 500_000,
17
20
  "execution_mode" => "subagent-driven",
18
21
  "hash_length" => 6,
19
22
  "hash_algorithm" => "sha256-base36",
@@ -118,6 +118,12 @@ class Rollback < InstallerCore
118
118
  remove_claude_hooks(path)
119
119
  stripped << path
120
120
  end
121
+ # The compact-instructions block (intent 312), for the same reason: no older
122
+ # package knows the section exists, so nothing there would ever replace or
123
+ # remove it. The Codex AGENTS.md section needs no equivalent: every older
124
+ # package knows that one and rewrites it on the downgrade install.
125
+ claude_md = strip_claude_compact_section(File.join(agent[:dir], "CLAUDE.md"))
126
+ stripped << claude_md if claude_md
121
127
  when "codex"
122
128
  path = agent[:home_dir] && File.join(agent[:home_dir], "hooks.json")
123
129
  if path && File.exist?(path)
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # write-handoff (intent 311): render and write one session's hand-off into
6
+ # the day directory (.sessions/<day>/handoff--<session>.md). Derived from the
7
+ # day ledger, so it can be regenerated at any time. Prints the path.
8
+ #
9
+ # Usage:
10
+ # write-handoff --trigger tick|precompact|close [--store <dir>] [--day <YYYYMMDD>]
11
+ # [--session <id>] [--templates <dir>]
12
+ #
13
+ # Defaults match append-ledger: the store is $PLASTIC_HOME/store, the day is
14
+ # today (local wall clock), the session is $CLAUDE_CODE_SESSION_ID, and the
15
+ # templates dir is the one shipped next to this script.
16
+ #
17
+ # Exit codes: 0 done; 2 usage error.
18
+
19
+ require_relative "lib/session_ledger"
20
+ require_relative "lib/handoff"
21
+
22
+ def usage_abort(message)
23
+ warn "write-handoff: #{message}"
24
+ exit 2
25
+ end
26
+
27
+ def expand(path)
28
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
29
+ end
30
+
31
+ def parse_args(argv)
32
+ opts = {}
33
+ i = 0
34
+ while i < argv.length
35
+ flag = argv[i]
36
+ usage_abort("unknown argument #{flag.inspect}") unless flag.start_with?("--")
37
+ usage_abort("#{flag} requires a value") if i + 1 >= argv.length
38
+ key = flag.delete_prefix("--").to_sym
39
+ usage_abort("unknown flag #{flag.inspect}") unless %i[store day session trigger templates].include?(key)
40
+ opts[key] = argv[i + 1]
41
+ i += 2
42
+ end
43
+ opts
44
+ end
45
+
46
+ opts = parse_args(ARGV)
47
+ usage_abort("--trigger is required (#{Handoff::TRIGGERS.join(', ')})") unless opts[:trigger]
48
+ usage_abort("--trigger must be one of #{Handoff::TRIGGERS.join(', ')}") unless Handoff::TRIGGERS.include?(opts[:trigger])
49
+
50
+ plastic_home = expand(ENV.fetch("PLASTIC_HOME", "~/.plastic"))
51
+ store = opts[:store] ? expand(opts[:store]) : File.join(plastic_home, "store")
52
+ templates = opts[:templates] ? expand(opts[:templates]) : File.expand_path("../templates", __dir__)
53
+ usage_abort("templates dir not found: #{templates}") unless Dir.exist?(templates)
54
+
55
+ day = opts[:day] || SessionLedger.day_id
56
+ usage_abort("--day must be eight digits parsing as a real date, got #{day.inspect}") unless SessionLedger.valid_day_id?(day)
57
+ session = SessionLedger.short_session_id(opts[:session], ENV["CLAUDE_CODE_SESSION_ID"])
58
+
59
+ puts Handoff.write(store: store, day: day, session: session, trigger: opts[:trigger], templates: templates)
60
+ exit 0
@@ -95,8 +95,9 @@ For a live intent's directory:
95
95
  ```bash
96
96
  ruby -r ~/.plastic/scripts/lib/savepoint -e 'Savepoint.rebuild_savepoint("<intent_dir>")'
97
97
  ```
98
- 3. **Read the hand-off.** When `resources/handoff--*.md` exists, its newest file is the prior
99
- session's own account of where things stand; read it after the ledger, never instead of it.
98
+ 3. **Read the hand-off.** The newest `~/.plastic/store/.sessions/<day>/handoff--*.md` (today,
99
+ else the newest prior day) is the prior session's own account of where things stand; read
100
+ it after the ledger, never instead of it.
100
101
  4. **Derive the next step:** the first unchecked item in `checklist.md` when it exists, else
101
102
  the next thing the station needs (see the matrix). The newest `## Insights` entry supplies
102
103
  the human-readable context; an entry marked `(autonomous)` means an auto team was
@@ -2,6 +2,11 @@ version: 3
2
2
  project_roots:
3
3
  - ~/.plastic/projects
4
4
  stale_threshold_days: 3
5
+ # When to compact. Absolute token counts for a 1M window (35 and 50 percent), not
6
+ # percentages: models are reliable only to roughly 50 to 65 percent of advertised
7
+ # context, so a percentage that is right at 200k floats to an untested size at 1M.
8
+ context_offer_tokens: 350000
9
+ context_insist_tokens: 500000
5
10
  execution_mode: subagent-driven
6
11
  hash_length: 6
7
12
  hash_algorithm: sha256-base36