@zalom/plastic 2.0.0-alpha.23 → 2.0.0-alpha.25

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 (67) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-advisor.md +1 -1
  3. package/agents/plastic-node-research.md +4 -4
  4. package/agents/plastic-node-verify.md +4 -4
  5. package/agents/plastic-node-work.md +4 -4
  6. package/package.json +1 -1
  7. package/scripts/dashboard.rb +1 -1
  8. package/scripts/end-intent +19 -16
  9. package/scripts/exec-worktree +5 -5
  10. package/scripts/hook-call-budget +6 -6
  11. package/scripts/hook-capture +15 -15
  12. package/scripts/hook-record +21 -14
  13. package/scripts/hook-savepoint +2 -1
  14. package/scripts/hook-session-start +9 -10
  15. package/scripts/lib/active_delivery.rb +44 -0
  16. package/scripts/lib/agent_models.rb +11 -0
  17. package/scripts/lib/arm.rb +41 -102
  18. package/scripts/lib/codex_adapter.rb +2 -2
  19. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  20. package/scripts/lib/day_summary.rb +19 -11
  21. package/scripts/lib/doctor_core.rb +1 -1
  22. package/scripts/lib/doctor_session_ledger.rb +3 -52
  23. package/scripts/lib/exec_worktree.rb +24 -21
  24. package/scripts/lib/graph_measure_budget.rb +29 -28
  25. package/scripts/lib/handoff.rb +4 -8
  26. package/scripts/lib/harness_adapter.rb +6 -6
  27. package/scripts/lib/index_entry.rb +53 -0
  28. package/scripts/lib/insights.rb +1 -1
  29. package/scripts/lib/installer_core.rb +143 -14
  30. package/scripts/lib/lock.rb +8 -9
  31. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  32. package/scripts/lib/node_input_compatibility.rb +62 -0
  33. package/scripts/lib/node_ledger.rb +4 -2
  34. package/scripts/lib/outcome_report.rb +1 -1
  35. package/scripts/lib/project_config.rb +45 -0
  36. package/scripts/lib/ready_set.rb +1 -1
  37. package/scripts/lib/roadmap_savepoint.rb +1 -1
  38. package/scripts/lib/runner_absorb.rb +5 -5
  39. package/scripts/lib/runner_dispatch.rb +44 -44
  40. package/scripts/lib/runner_sweep.rb +4 -4
  41. package/scripts/lib/runner_until_empty.rb +1 -1
  42. package/scripts/lib/savepoint.rb +6 -7
  43. package/scripts/lib/scaffold_intent.rb +6 -3
  44. package/scripts/lib/session_close.rb +30 -28
  45. package/scripts/lib/session_git.rb +20 -14
  46. package/scripts/lib/session_ledger.rb +44 -4
  47. package/scripts/lib/worktree.rb +24 -24
  48. package/scripts/lib/worktree_sweep.rb +3 -3
  49. package/scripts/{node-packet → node-input} +15 -15
  50. package/scripts/node-run +19 -19
  51. package/scripts/plastic-lock +33 -32
  52. package/scripts/runner +1 -1
  53. package/skills/auto/SKILL.md +7 -7
  54. package/skills/auto/references/end-tail.md +9 -11
  55. package/skills/auto/references/human-report-contract.md +1 -1
  56. package/skills/conventions/references/completion-and-done.md +9 -10
  57. package/skills/conventions/references/knowledge-graph.md +1 -1
  58. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  59. package/skills/direct/SKILL.md +2 -2
  60. package/skills/doctor/SKILL.md +1 -1
  61. package/skills/intent-executing/SKILL.md +1 -1
  62. package/skills/releasing/SKILL.md +2 -2
  63. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  64. package/skills/releasing/references/release-lines.md +1 -1
  65. package/templates/index.md +1 -1
  66. package/templates/project.yml +1 -1
  67. package/scripts/lib/bridge.rb +0 -116
@@ -6,27 +6,28 @@ require "json"
6
6
  require "tmpdir"
7
7
  require_relative "node_ledger"
8
8
  require_relative "node_file"
9
- require_relative "node_packet"
10
- require_relative "packet_wrapper"
9
+ require_relative "node_input"
10
+ require_relative "node_input_compatibility"
11
+ require_relative "data_boundary"
11
12
 
12
13
  # GraphMeasureBudget (intent 343, G10, n4): whether the `budget:` a node's
13
14
  # envelope declares (327 C19, D47) is a ceiling that ever actually held.
14
- # Intent 340 found at 22:14Z on 2026-09-10 that every runner-built packet
15
+ # Intent 340 found at 22:14Z on 2026-09-10 that every runner-built node input
15
16
  # was capped at 8000 tokens whatever the node declared (v1 major M7); this
16
17
  # module is the measurement of whether that still holds, from the ledger
17
- # and the real packet files alone.
18
+ # and the real input files alone.
18
19
  #
19
20
  # Read-only (spec D2): it opens `nodes/*.md`, `savepoint.md` and files under
20
- # `packets/`, and writes nothing. A measure with no source prints
21
- # `:unavailable`, never zero and never blank (spec D3); a packet file named
22
- # by `packet=` that is not on disk is counted and named by its sha, never
21
+ # `attempts/`, and writes nothing. A measure with no source prints
22
+ # `:unavailable`, never zero and never blank (spec D3); a input file named
23
+ # by `input=` that is not on disk is counted and named by its sha, never
23
24
  # silently treated as zero bytes (spec D4).
24
25
  #
25
26
  # Adds no second parser and no second estimator: `NodeLedger` owns the
26
27
  # transition-line format, `NodeFile` owns the envelope's `budget:`,
27
- # `NodePacket` owns packet path resolution and attempt numbering, and
28
- # `PacketWrapper.estimate_tokens` is the one token formula, the same the
29
- # packet builder enforced with (spec D8).
28
+ # `NodeInput` owns node input path resolution and attempt numbering, and
29
+ # `DataBoundary.estimate_tokens` is the one token formula, the same the
30
+ # input builder enforced with (spec D8).
30
31
  module GraphMeasureBudget
31
32
  module_function
32
33
 
@@ -35,7 +36,7 @@ module GraphMeasureBudget
35
36
 
36
37
  # {ok:, nodes: {id => {declared_budget:, attempts: [...]}}, ceiling: {...}}.
37
38
  # Never raises: a missing savepoint.md, a node with no envelope file, and a
38
- # packet= naming a file that does not exist on disk all resolve to
39
+ # input= naming a file that does not exist on disk all resolve to
39
40
  # `:unavailable` fields rather than an exception.
40
41
  def read(intent_dir, node_reader: NodeFile.method(:parse))
41
42
  dir = File.expand_path(intent_dir.to_s)
@@ -54,12 +55,12 @@ module GraphMeasureBudget
54
55
  declared_budget = declared_budget_for(dir, subject, node_reader)
55
56
  attempts = indices.map do |idx|
56
57
  entry = entries[idx]
57
- # Row 4.5: the attempt number is NodePacket's own count of non-torn
58
+ # Row 4.5: the attempt number is NodeInput's own count of non-torn
58
59
  # `running` lines up to and including this one, never
59
60
  # ReadySet.attempts_count (which resets after `done`, `superseded`
60
61
  # or `abandoned` and answers "attempts left", not "which attempt was
61
62
  # this line").
62
- attempt_number = NodePacket.compute_attempt_number(
63
+ attempt_number = NodeInput.compute_attempt_number(
63
64
  intent_dir: dir, node: subject, lease_flag_given: false, entries: entries[0..idx]
64
65
  )
65
66
  build_attempt(dir, subject, attempt_number, entry, declared_budget)
@@ -105,9 +106,9 @@ module GraphMeasureBudget
105
106
  def attempt_model(a)
106
107
  {
107
108
  "attempt" => a[:attempt],
108
- "packet_sha_declared" => av(a[:packet_sha_declared]),
109
+ "input_sha_declared" => av(a[:input_sha_declared]),
109
110
  "file_exists" => a[:file_exists],
110
- "packet_sha_actual" => av(a[:packet_sha_actual]),
111
+ "input_sha_actual" => av(a[:input_sha_actual]),
111
112
  "sha_match" => a[:sha_match],
112
113
  "bytes" => av(a[:bytes]),
113
114
  "estimate_tokens" => av(a[:estimate_tokens]),
@@ -132,7 +133,7 @@ module GraphMeasureBudget
132
133
  def node_block(nodes)
133
134
  lines = ["== Budget =="]
134
135
  if nodes.empty?
135
- lines << "(no packet attempts recorded)"
136
+ lines << "(no node input attempts recorded)"
136
137
  return lines
137
138
  end
138
139
 
@@ -179,7 +180,7 @@ module GraphMeasureBudget
179
180
  # n5's `GraphMeasureModels` already uses for its own `present?` and
180
181
  # `resolve_kind`): a local copy, not a second parser or a reopen.
181
182
  def declared_budget_for(dir, node, node_reader)
182
- path = NodePacket.find_node_path(dir, node)
183
+ path = NodeInput.find_node_path(dir, node)
183
184
  return :unavailable unless path
184
185
 
185
186
  parsed = with_safe_path(path) { |p| p ? node_reader.call(p) : nil }
@@ -209,22 +210,22 @@ module GraphMeasureBudget
209
210
 
210
211
  def build_attempt(dir, subject, attempt_number, entry, declared_budget)
211
212
  fields = entry[:fields] || {}
212
- packet_sha_declared = present?(fields["packet"]) ? fields["packet"] : :unavailable
213
+ input_sha_declared = present?(fields["input"]) ? fields["input"] : :unavailable
213
214
  # D8: hop= is written once, on the running line; absent means no hop
214
215
  # block was appended (the feature predates this line, or hop is off),
215
216
  # never an unknown quantity to subtract.
216
217
  hop = present?(fields["hop"]) ? fields["hop"].to_i : 0
217
- path = NodePacket.packet_path(intent_dir: dir, node: subject, attempt: attempt_number)
218
+ path = NodeInputCompatibility.input_path(intent_dir: dir, node: subject, attempt: attempt_number)
218
219
  file_exists = File.exist?(path)
219
220
 
220
221
  if file_exists
221
222
  raw = File.binread(path)
222
223
  bytes = raw.bytesize
223
224
  sha_actual = Digest::SHA256.hexdigest(raw)[0, 12]
224
- estimate = PacketWrapper.estimate_tokens(raw)
225
+ estimate = DataBoundary.estimate_tokens(raw)
225
226
  effective = estimate - hop
226
227
  over_budget = declared_budget.is_a?(Integer) ? effective > declared_budget : :unavailable
227
- sha_match = sha_actual == packet_sha_declared
228
+ sha_match = sha_actual == input_sha_declared
228
229
  else
229
230
  bytes = :unavailable
230
231
  sha_actual = :unavailable
@@ -236,10 +237,10 @@ module GraphMeasureBudget
236
237
 
237
238
  {
238
239
  attempt: attempt_number,
239
- packet_sha_declared: packet_sha_declared,
240
- packet_path: path,
240
+ input_sha_declared: input_sha_declared,
241
+ input_path: path,
241
242
  file_exists: file_exists,
242
- packet_sha_actual: sha_actual,
243
+ input_sha_actual: sha_actual,
243
244
  sha_match: sha_match,
244
245
  bytes: bytes,
245
246
  estimate_tokens: estimate,
@@ -258,13 +259,13 @@ module GraphMeasureBudget
258
259
 
259
260
  # --- the suspected ceiling: spec matrix rows 4.8, 4.9 -----------------------
260
261
 
261
- # A run of packets whose estimates all fall under one value well below
262
+ # A run of node inputs whose estimates all fall under one value well below
262
263
  # their own declared budgets (row 4.8): the candidate ceiling is the
263
264
  # largest effective estimate among the usable attempts (every attempt
264
- # whose declared budget and packet file are both known), and it is only
265
+ # whose declared budget and input file are both known), and it is only
265
266
  # reported when that value sits strictly under EVERY one of those
266
267
  # attempts' own declared budgets - never a "cluster within a few percent"
267
- # of each other, which never fires against a real spread of packet sizes.
268
+ # of each other, which never fires against a real spread of node input sizes.
268
269
  # Row 4.9: fewer than two distinct nodes never reports a ceiling; one
269
270
  # node's own repeated attempts are not evidence of a ceiling shared across
270
271
  # the intent.
@@ -280,7 +281,7 @@ module GraphMeasureBudget
280
281
  # "Well below" needs a stated threshold: WELL_BELOW_RATIO is that
281
282
  # threshold, and a ceiling is reported only when the candidate uses no
282
283
  # more than this fraction of EVERY usable attempt's own declared budget.
283
- # Reproduced against 340's real packets: declared budgets there run from
284
+ # Reproduced against 340's real node inputs: declared budgets there run from
284
285
  # 50000 to 160000 tokens (327 D47's per-kind defaults), and every
285
286
  # attempt's effective token count clusters between 3380 and 7717 - the
286
287
  # candidate (7717) is at most 7717/50000 = 15.4% of even the SMALLEST
@@ -38,15 +38,11 @@ module Handoff
38
38
  File.join(SessionLedger.day_dir(store, day), "handoff--#{session}.md")
39
39
  end
40
40
 
41
- # The day this session's hand-off belongs to: the pointer's day id when
42
- # the pointer holds one, else today (no pointer, or a pointer naming an
43
- # intent), the same fallback SessionClose uses for the drop at close.
41
+ # The day this session's hand-off belongs to: the session's day from the
42
+ # day ledger (SessionLedger.session_day, graph.md D7), the same lookup
43
+ # SessionClose uses for the drop at close.
44
44
  def day_for(store, session, today:)
45
- path = SessionLedger.pointer_path(store, session)
46
- return today unless File.exist?(path)
47
-
48
- value = File.read(path).strip
49
- SessionLedger.valid_day_id?(value) ? value : today
45
+ SessionLedger.session_day(store, session, today: today)
50
46
  end
51
47
 
52
48
  def clip(summary)
@@ -64,7 +64,7 @@ module HarnessAdapter
64
64
  # squashed (whitespace collapsed, matrix row 1.18) before it is even
65
65
  # compared, so a config-authored value carrying a tab or a newline can
66
66
  # never reach `NodeLedger` unsquashed and raise `ArgumentError` mid-dispatch,
67
- # after the packet is already built. A value that still is not one of
67
+ # after the node input is already built. A value that still is not one of
68
68
  # KNOWN_KEYS after squashing falls back to DEFAULT_KEY with a warning
69
69
  # (never to no adapter), so this method's return is always one of the two
70
70
  # known, clean strings - safe to hand straight to a ledger field.
@@ -101,7 +101,7 @@ module HarnessAdapter
101
101
  # list (matrix row 1.14: a step that dispatched nothing prints no
102
102
  # instruction block for a session to act on). `dispatched` is the runner's
103
103
  # own plan entries (RunnerDispatch's `:dispatched` shape): [{node:, kind:,
104
- # role:, model:, worktree:, packet:}, ...]. The model and the packet path
104
+ # role:, model:, worktree:, input:}, ...]. The model and the node input path
105
105
  # ride straight from each entry - never a fresh lookup (matrix row 1.11)
106
106
  # and never a summary (matrix row 1.12) - and `return_contract` (the exact
107
107
  # text RunnerDispatch::RETURN_CONTRACT already carries in the YAML plan)
@@ -115,11 +115,11 @@ module HarnessAdapter
115
115
  end
116
116
 
117
117
  # The Claude Code rendering (spec Approach): per dispatched node, the agent
118
- # type from the kind, the model the plan already resolved, and the packet
118
+ # type from the kind, the model the plan already resolved, and the node input
119
119
  # path as the whole prompt.
120
120
  def render_claude_code(entries, return_contract)
121
121
  blocks = entries.map do |d|
122
- "Dispatch #{agent_type_for_kind(d[:kind])} for #{d[:node]} (model: #{d[:model]}):\n prompt: #{d[:packet]}"
122
+ "Dispatch #{agent_type_for_kind(d[:kind])} for #{d[:node]} (model: #{d[:model]}):\n prompt: #{d[:input]}"
123
123
  end
124
124
  "#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
125
125
  end
@@ -128,13 +128,13 @@ module HarnessAdapter
128
128
  # The Codex rendering: a Codex node runs end to end through `scripts/
129
129
  # node-run` (n6), which reads the ledger's own `running` line directly and
130
130
  # never through this printed block - so this names, per node, the same
131
- # facts the block gives Claude Code (kind, model, packet path) framed as
131
+ # facts the block gives Claude Code (kind, model, node input path) framed as
132
132
  # `node-run` calls, proving the seam renders SOMETHING for the second
133
133
  # harness rather than nothing (matrix row 1.27), without inventing detail
134
134
  # that belongs to n6's own adapter.
135
135
  def render_codex(entries, return_contract)
136
136
  blocks = entries.map do |d|
137
- "node-run #{d[:node]} (#{d[:kind]}, model: #{d[:model]}):\n packet: #{d[:packet]}"
137
+ "node-run #{d[:node]} (#{d[:kind]}, model: #{d[:model]}):\n input: #{d[:input]}"
138
138
  end
139
139
  "#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
140
140
  end
@@ -0,0 +1,53 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # IndexEntry - the shared INDEX.md entry matcher and Active check (intent 188,
5
+ # D12/D13; moved off the retired shared-helpers module by intent 344, D2).
6
+ #
7
+ # ONE definition site for the "- [ID <sep> Title](link)" shape both `active?`
8
+ # (below) and `scripts/end-intent`'s own INDEX-move parser depend on, so the
9
+ # two regexes can never drift apart. Accepts a real em dash (U+2014) OR a
10
+ # plain hyphen as the id/title separator on READ; every WRITE still emits the
11
+ # real em dash (D10). The separator is built from the codepoint, not a
12
+ # literal byte, so this file stays em-dash free.
13
+ module IndexEntry
14
+ module_function
15
+
16
+ EM_DASH = "\u2014".freeze
17
+ ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
18
+
19
+ # Match `line` (already chomped) against the shared INDEX entry shape.
20
+ # Returns a MatchData (captures: 1 = id, 2 = title, 3 = link) or nil.
21
+ def match(line)
22
+ line.to_s.match(ENTRY_RE)
23
+ end
24
+
25
+ # True iff the intent is Active in its store's INDEX.md, which lives at the
26
+ # PARENT of the store/ dir. Non-raising: any failure (missing or unreadable
27
+ # INDEX, bad arg) returns false. `index_active_ids` is a pure-data test
28
+ # seam: when an Array of id strings is supplied, membership is checked
29
+ # against it directly with no file read.
30
+ def active?(intent_id, store:, index_active_ids: nil)
31
+ target = intent_id.to_s
32
+ return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
33
+
34
+ index = File.join(File.dirname(store.to_s), "INDEX.md")
35
+ return false unless File.exist?(index)
36
+
37
+ in_active = false
38
+ File.foreach(index) do |line|
39
+ stripped = line.chomp
40
+ if stripped == "## Active"
41
+ in_active = true
42
+ next
43
+ end
44
+ next unless in_active
45
+ break if stripped.start_with?("## ") # next section ends the Active block
46
+ m = match(stripped)
47
+ return true if m && m[1] == target
48
+ end
49
+ false
50
+ rescue StandardError
51
+ false
52
+ end
53
+ end
@@ -2,7 +2,7 @@
2
2
  # encoding: UTF-8
3
3
 
4
4
  # insights.rb - the blessed write path for an intent's `## Insights` section
5
- # (intent 82). A sibling library to bridge.rb so the insight write path stays
5
+ # (intent 82). A standalone library so the insight write path stays
6
6
  # independent of the savepoint ledger (the spec forbids touching the ledger).
7
7
  #
8
8
  # Every entry leads with a fixed, machine-parseable prefix
@@ -6,6 +6,7 @@ require "yaml"
6
6
  require "fileutils"
7
7
  require "digest"
8
8
  require "time"
9
+ require "tmpdir"
9
10
  require_relative "hook_registry"
10
11
  require_relative "agent_models"
11
12
  require_relative "harness_text"
@@ -254,7 +255,10 @@ class InstallerCore
254
255
 
255
256
  # --- Distribution phase ---
256
257
 
257
- def distribute(mode)
258
+ # `tmp_dirs:` defaults to the real system tmp directory plus `/tmp` (where a
259
+ # retired session-state leftover would sit); a test overrides it (often to
260
+ # `[]`) so the removal never scans the real filesystem's tmp directories.
261
+ def distribute(mode, tmp_dirs: [Dir.tmpdir, "/tmp"].uniq)
258
262
  puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{plastic_home}"
259
263
 
260
264
  manifest_path = File.join(plastic_home, "manifest.json")
@@ -296,6 +300,8 @@ class InstallerCore
296
300
  write_manifest(global_files, manifest_path)
297
301
 
298
302
  pruned = prune_removed_files(old_files - global_files, root: plastic_home)
303
+ removed = remove_retired_session_state(plastic_home: plastic_home, tmp_dirs: tmp_dirs)
304
+ puts " \u{1f9f9} #{removed} retired session state file(s) removed" if removed.positive?
299
305
 
300
306
  puts " \u{2705} Core files synced (v#{version})#{pruned.positive? ? ", #{pruned} stale file(s) pruned" : ""}"
301
307
  end
@@ -372,10 +378,12 @@ class InstallerCore
372
378
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
373
379
  "scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
374
380
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
375
- "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
381
+ "scripts/lib/index_entry.rb" => "scripts/lib/index_entry.rb",
382
+ "scripts/lib/project_config.rb" => "scripts/lib/project_config.rb",
376
383
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
377
384
  "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
378
385
  "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
386
+ "scripts/lib/node_input_compatibility.rb" => "scripts/lib/node_input_compatibility.rb",
379
387
  "scripts/lib/node_progress.rb" => "scripts/lib/node_progress.rb",
380
388
  "scripts/node-transition" => "scripts/node-transition",
381
389
  "scripts/lib/arm.rb" => "scripts/lib/arm.rb",
@@ -513,11 +521,11 @@ class InstallerCore
513
521
  "scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
514
522
  "scripts/roadmap-graph" => "scripts/roadmap-graph",
515
523
  "scripts/index-projection" => "scripts/index-projection",
516
- # Intent 338 (G5): the node packet command - the trust-boundary wrapper,
524
+ # Intent 338 (G5): the node input command - the trust-boundary wrapper,
517
525
  # the five-block gatherer/assembler, and the CLI 340's runner calls.
518
- "scripts/lib/packet_wrapper.rb" => "scripts/lib/packet_wrapper.rb",
519
- "scripts/lib/node_packet.rb" => "scripts/lib/node_packet.rb",
520
- "scripts/node-packet" => "scripts/node-packet",
526
+ "scripts/lib/data_boundary.rb" => "scripts/lib/data_boundary.rb",
527
+ "scripts/lib/node_input.rb" => "scripts/lib/node_input.rb",
528
+ "scripts/node-input" => "scripts/node-input",
521
529
  # Intent 342 (G9): the backward shim that presents actions/*.md as a
522
530
  # node graph for any legacy intent, so WorkGraphValidator can require
523
531
  # it without going red on contact with install_sync_test.
@@ -593,7 +601,7 @@ class InstallerCore
593
601
  "scripts/lib/graph_measure_report.rb" => "scripts/lib/graph_measure_report.rb",
594
602
  # Intent 343 (G10, n4): the `budget` verb - whether the `budget:` a
595
603
  # node's envelope declares (327 C19) is a ceiling that ever actually
596
- # held, read from the ledger and the real packet files.
604
+ # held, read from the ledger and the real input files.
597
605
  "scripts/lib/graph_measure_budget.rb" => "scripts/lib/graph_measure_budget.rb",
598
606
  # Intent 343 (G10, n5): the `cohorts` verb's model section - the store
599
607
  # walk and whether a recorded model= still matches what config
@@ -885,6 +893,111 @@ class InstallerCore
885
893
  removed
886
894
  end
887
895
 
896
+ # Intent 344 (G11, D10): leftovers from the retired per-session day pointer
897
+ # and the retired inter-hook coordination key. A `current` candidate is
898
+ # real only when reached with no symlink between plastic_home and the
899
+ # session directory: a store root's own real path must equal the plain
900
+ # join of plastic_home's real path with the store's lexical relative path
901
+ # (a symlinked store, or a symlinked projects/<slug> above it, breaks
902
+ # that equality), and neither the store's `.tmp` directory nor the
903
+ # session directory beneath it may itself be a symlink. A tmp-file
904
+ # candidate is judged by its containing directory's real path instead,
905
+ # so a symlinked tmp root such as macOS's real /tmp still works; its
906
+ # name is matched on raw bytes so an undecodable name never raises.
907
+ # Never opens or parses a candidate: a torn or malformed leftover is
908
+ # deleted by name alone, exactly like a well-formed one. Only a regular
909
+ # file that is not a symlink is ever a candidate for deletion. Returns
910
+ # the count removed; a delete failure warns once per path and the sync
911
+ # continues, since a stuck leftover must never fail an install.
912
+ def remove_retired_session_state(plastic_home:, tmp_dirs:)
913
+ candidates = []
914
+
915
+ real_store_roots(plastic_home).each do |store_root|
916
+ candidates.concat(current_candidates(store_root))
917
+ end
918
+
919
+ Array(tmp_dirs).compact.each do |dir|
920
+ candidates.concat(tmp_json_candidates(dir))
921
+ end
922
+
923
+ removed = 0
924
+ candidates.uniq.each do |candidate|
925
+ begin
926
+ next unless File.lstat(candidate).file?
927
+ rescue SystemCallError
928
+ next
929
+ end
930
+
931
+ begin
932
+ File.delete(candidate)
933
+ removed += 1
934
+ rescue SystemCallError => e
935
+ warn " \u{26a0}\u{fe0f} Could not remove retired session state #{candidate}: #{e.message}"
936
+ end
937
+ end
938
+ removed
939
+ rescue StandardError
940
+ removed || 0
941
+ end
942
+
943
+ # Every store root (the global store, plus each project store) whose real
944
+ # path is reached with no symlink between it and plastic_home.
945
+ def real_store_roots(plastic_home)
946
+ roots = [File.join(plastic_home, "store")] +
947
+ Dir.glob(File.join(plastic_home, "projects", "*", "store"))
948
+ roots.select { |root| real_store_root?(plastic_home, root) }
949
+ end
950
+
951
+ def real_store_root?(plastic_home, store_root)
952
+ return false unless File.directory?(store_root)
953
+
954
+ real_home = File.realpath(plastic_home)
955
+ relative = store_root.sub(/\A#{Regexp.escape(plastic_home)}\/?/, "")
956
+ File.realpath(store_root) == File.join(real_home, relative)
957
+ rescue SystemCallError
958
+ false
959
+ end
960
+
961
+ # `current` candidates under one validated store root: `.tmp` and every
962
+ # session directory beneath it must be real directories, never symlinks.
963
+ def current_candidates(store_root)
964
+ tmp_root = File.join(store_root, ".tmp")
965
+ return [] unless File.lstat(tmp_root).directory?
966
+
967
+ Dir.children(tmp_root).filter_map do |child|
968
+ begin
969
+ session_dir = File.join(tmp_root, child)
970
+ next unless File.lstat(session_dir).directory?
971
+
972
+ File.join(session_dir, "current")
973
+ rescue SystemCallError
974
+ nil
975
+ end
976
+ end
977
+ rescue SystemCallError
978
+ []
979
+ end
980
+
981
+ # Retired session-state leftover files sitting directly in `dir`, resolved through
982
+ # `dir`'s own real path so a symlinked tmp root (macOS's real /tmp) still
983
+ # works; the name match runs on raw bytes so an undecodable name is
984
+ # simply not a match, never a raised error.
985
+ def tmp_json_candidates(dir)
986
+ real = File.realpath(dir)
987
+ Dir.children(real).select { |name| retired_tmp_name?(name) }.map { |name| File.join(real, name) }
988
+ rescue SystemCallError
989
+ []
990
+ end
991
+
992
+ # True when `name`'s raw bytes match the retired session-state leftover shape
993
+ # (`plastic-<session>--<id>.json`). Runs on `name.b` so a name that is
994
+ # not valid UTF-8 is judged on bytes instead of raising.
995
+ def retired_tmp_name?(name)
996
+ name.b.match?(/\Aplastic-[^\/]+--[^\/]+\.json\z/n)
997
+ rescue StandardError
998
+ false
999
+ end
1000
+
888
1001
  # True when `path`, once expanded, is one of `roots` itself or lives underneath
889
1002
  # one of them. String-prefix containment guarded by a trailing separator so a
890
1003
  # sibling directory that merely shares a prefix (`/home/x-evil` vs root `/home/x`)
@@ -968,7 +1081,7 @@ class InstallerCore
968
1081
  installed += install_skills_flat(skills_source, skills_root, exclude: skill_exclude) if File.directory?(skills_source)
969
1082
 
970
1083
  # Copy agent role files into <dir>/agents (manifest-tracked, pruned on update)
971
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
1084
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, efforts: agent_effort_overrides, advisor_enabled: advisor_enabled?)
972
1085
 
973
1086
  # Write VERSION
974
1087
  version_file = File.join(plastic_dir, "VERSION")
@@ -1201,7 +1314,7 @@ class InstallerCore
1201
1314
  skills_source = File.join(package_root, "skills")
1202
1315
  skill_exclude = advisor_enabled? ? [] : ["agent-advisor"]
1203
1316
  installed += install_skills_flat(skills_source, File.join(config[:dir], "skills"), exclude: skill_exclude) if File.directory?(skills_source)
1204
- installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, advisor_enabled: advisor_enabled?)
1317
+ installed += install_agents(File.join(config[:dir], "agents"), models: agent_model_overrides, efforts: agent_effort_overrides, advisor_enabled: advisor_enabled?)
1205
1318
 
1206
1319
  # Uniform per-agent record (intent 210, D2): write VERSION alongside the manifest,
1207
1320
  # the same shape install_claude already writes.
@@ -1252,7 +1365,7 @@ class InstallerCore
1252
1365
  # AgentModels::CONSULTATION_AGENTS file entirely (both plastic-advisor and
1253
1366
  # plastic-faux-advisor), so a user who declined the advisor never gets either
1254
1367
  # agent installed.
1255
- def install_agents(agents_root, models: {}, advisor_enabled: true)
1368
+ def install_agents(agents_root, models: {}, efforts: {}, advisor_enabled: true)
1256
1369
  sources = Dir.glob(File.join(package_root, "agents", "*.md"))
1257
1370
  return [] if sources.empty?
1258
1371
 
@@ -1264,9 +1377,13 @@ class InstallerCore
1264
1377
  sources.map do |src|
1265
1378
  dest = File.join(agents_root, File.basename(src))
1266
1379
  basename = File.basename(src, ".md")
1267
- override = models[basename]
1268
- if override
1269
- File.write(dest, rewrite_model_line(File.read(src), override))
1380
+ model = models[basename]
1381
+ effort = efforts[basename]
1382
+ if model || effort
1383
+ content = File.read(src)
1384
+ content = rewrite_model_line(content, model) if model
1385
+ content = rewrite_effort_line(content, effort) if effort
1386
+ File.write(dest, content)
1270
1387
  else
1271
1388
  FileUtils.cp(src, dest)
1272
1389
  end
@@ -1280,6 +1397,12 @@ class InstallerCore
1280
1397
  content.sub(/^model:[^\n]*$/, "model: #{model}")
1281
1398
  end
1282
1399
 
1400
+ def rewrite_effort_line(content, effort)
1401
+ return content.sub(/^effort:[^\n]*$/, "effort: #{effort}") if content.match?(/^effort:/)
1402
+
1403
+ content.sub(/^(model:[^\n]*)$/) { "#{Regexp.last_match(1)}\neffort: #{effort}" }
1404
+ end
1405
+
1283
1406
  # Resolve per-agent model overrides for this install: project config (when a
1284
1407
  # project dir is known) overlaid on global config, scoped to `harness`
1285
1408
  # ("claude" or "codex"). Defaults are NOT included, so unconfigured agents
@@ -1303,6 +1426,12 @@ class InstallerCore
1303
1426
  AgentModels.override_map(project_config: project_config, global_config: global_config, harness: harness)
1304
1427
  end
1305
1428
 
1429
+ def agent_effort_overrides(project_dir = nil, harness: "claude")
1430
+ global_config = load_config_yaml(File.join(plastic_home, "config.yml"))
1431
+ project_config = project_dir ? load_config_yaml(File.join(project_dir, ".plastic_store", "config.yml")) : {}
1432
+ AgentModels.effort_override_map(project_config: project_config, global_config: global_config, harness: harness)
1433
+ end
1434
+
1306
1435
  # advisor.enabled: project overlays global, missing or malformed counts as
1307
1436
  # enabled (fail-open). Harness-blind: false skips both advisor agents and the
1308
1437
  # agent-advisor skill on every installed harness.
@@ -1453,7 +1582,7 @@ class InstallerCore
1453
1582
  puts " \u{2139}\u{fe0f} Kept #{kept.size} hook(s) Plastic does not own, named with the reserved plastic- prefix:"
1454
1583
  kept.each { |cmd| puts " - #{tilde(cmd.to_s)}" }
1455
1584
  puts " The plastic- prefix is reserved for Plastic's own hooks. Rename yours (for"
1456
- puts " example ~/.claude/hooks/writing-style) so a future update never mistakes it."
1585
+ puts " example ~/.claude/hooks/plain-writing) so a future update never mistakes it."
1457
1586
  end
1458
1587
 
1459
1588
  # --- settings.json merge (read-modify-write, never clobber) ---
@@ -12,8 +12,8 @@ require "time"
12
12
  # savepoint.md (git-ignored, transient state). Ownership is session-keyed (D1):
13
13
  # the file records the owner session, never a pid. Liveness is a lease: the
14
14
  # owner's hooks touch the file mtime on tool calls (heartbeat); the lock is
15
- # stale only when that heartbeat is older than the TTL. The /tmp bridge is a
16
- # per-session CACHE of this state; on any disagreement the lock file wins (D2).
15
+ # stale only when that heartbeat is older than the TTL. This lock file is
16
+ # the sole truth of ownership (D2).
17
17
  #
18
18
  # Mutual-exclusion seam (D3): the schema carries a type ("delivery" now,
19
19
  # "maintenance" in a chained intent after 93) and acquire refuses while the
@@ -27,13 +27,12 @@ module Lock
27
27
  # Skill-invocation prefix per harness (intent 201, D2/D3). Claude Code invokes a
28
28
  # skill with a slash (/plastic-doctor); Codex CLI invokes explicitly with a
29
29
  # dollar ($plastic-doctor) and may also select one implicitly by matching the
30
- # skill's description. This table is the actual source of truth for
31
- # Bridge.skill_ref (bridge.rb requires lock.rb, never the reverse, so the
32
- # table lives here rather than pulling Bridge into this dependency-free file
33
- # just to render two characters). InstallerCore::DEFAULT_AGENTS carries the
34
- # same values per adapter as documented config (see ACTION_2); this constant
35
- # is not read from it at runtime, by the same reasoning bridge.rb/hook-*
36
- # already stay clear of installer_core.rb (spec Alternatives Considered).
30
+ # skill's description. This table is the package-wide source of truth for
31
+ # `Lock.skill_ref`; every caller reaches it directly rather than duplicating
32
+ # the prefix table. InstallerCore::DEFAULT_AGENTS carries the same values per
33
+ # adapter as documented config (see ACTION_2); this constant is not read from
34
+ # it at runtime, by the same reasoning this file and hook-* already stay
35
+ # clear of installer_core.rb (spec Alternatives Considered).
37
36
  SKILL_PREFIXES = { "claude" => "/", "codex" => "$" }.freeze
38
37
 
39
38
  # Renders a skill reference for the given harness. Unset or unrecognized