@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24

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 (103) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. package/scripts/lib/bridge.rb +0 -116
@@ -0,0 +1,184 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # HarnessAdapter (intent 340b, G7c, n1): the one module in Plastic that knows
5
+ # a harness by name. It resolves the current key from config `agent.type`,
6
+ # renders a dispatch plan into that harness's instructions, and answers what
7
+ # the ledger should record. `runner step` calls it; nothing else in the
8
+ # runner does (spec D1).
9
+ #
10
+ # Pure: every input is an argument, nothing here reads ENV or a real
11
+ # config.yml itself - the caller (scripts/runner) owns loading real config,
12
+ # this module only resolves values out of what it is handed, the same
13
+ # discipline RunnerPolicy already holds for model resolution.
14
+ module HarnessAdapter
15
+ module_function
16
+
17
+ # The only two harnesses this module knows (spec D2). An unknown key -
18
+ # config-authored or from `--harness` - always falls back to this rather
19
+ # than to no adapter at all (matrix row 1.3): a plan with no rendering
20
+ # under it is worse than a plan rendered for the wrong harness, because the
21
+ # first is silent.
22
+ DEFAULT_KEY = "claude-code"
23
+ KNOWN_KEYS = %w[claude-code codex].freeze
24
+
25
+ # The exact sentence 340a's watch prints to stderr and exits 1 with,
26
+ # before any write, when `--dispatch` is asked of a harness
27
+ # #unattended_start? refuses (graph.md D7). Quoted verbatim in
28
+ # docs/reference/harness-adapters.md (D12); change the wording in one
29
+ # place only.
30
+ UNATTENDED_START_SENTENCE =
31
+ "Unattended start is delivered only where a Ruby loop owns dispatch (Codex, through runner " \
32
+ "watch --dispatch); on Claude Code the runner is the harness session (327 Q6), so arm /loop " \
33
+ "over runner watch in a live session instead."
34
+
35
+ # unattended_start?(key) -> true only for "codex" (graph.md D7): the one
36
+ # harness where a Ruby loop (RunnerUntilEmpty, through `runner watch
37
+ # --dispatch`) owns dispatch end to end with nobody on the other end
38
+ # making subagent calls. Claude Code IS the harness session (327 Q6), and
39
+ # an unknown key is never granted unattended start either - this module
40
+ # stays the only place that names a harness, so RunnerWatch asks this
41
+ # predicate and never compares a key against "codex" itself.
42
+ def unattended_start?(key)
43
+ key.to_s == "codex"
44
+ end
45
+
46
+ # kind -> the Claude Code agent type dispatched for it (spec Approach).
47
+ # Verify and research carry no `Bash` in their own frontmatter (n2); this
48
+ # table only names WHICH agent a kind maps to. An unknown kind gets
49
+ # `work`'s row, the widest of the three, never no agent type at all
50
+ # (matrix row 1.10), mirroring RunnerPolicy's own kind-table fallback.
51
+ AGENT_TYPE_BY_KIND = {
52
+ "work" => "plastic-node-work",
53
+ "verify" => "plastic-node-verify",
54
+ "research" => "plastic-node-research",
55
+ }.freeze
56
+
57
+ def agent_type_for_kind(kind)
58
+ AGENT_TYPE_BY_KIND.fetch(kind.to_s, AGENT_TYPE_BY_KIND["work"])
59
+ end
60
+
61
+ # resolve_key(config:, override:) -> the harness key for one call (matrix
62
+ # rows 1.1-1.3, 1.18). `override` (the CLI's `--harness KEY`, spec D2) wins
63
+ # over `config`'s own `agent.type` for that one call; either source is
64
+ # squashed (whitespace collapsed, matrix row 1.18) before it is even
65
+ # compared, so a config-authored value carrying a tab or a newline can
66
+ # never reach `NodeLedger` unsquashed and raise `ArgumentError` mid-dispatch,
67
+ # after the node input is already built. A value that still is not one of
68
+ # KNOWN_KEYS after squashing falls back to DEFAULT_KEY with a warning
69
+ # (never to no adapter), so this method's return is always one of the two
70
+ # known, clean strings - safe to hand straight to a ledger field.
71
+ def resolve_key(config: {}, override: nil)
72
+ raw = blank?(override) ? agent_type_from_config(config) : override.to_s
73
+ squashed = squash(raw)
74
+ return squashed if KNOWN_KEYS.include?(squashed)
75
+
76
+ warn "harness_adapter: unknown harness #{raw.inspect}, falling back to #{DEFAULT_KEY.inspect}"
77
+ DEFAULT_KEY
78
+ end
79
+
80
+ def agent_type_from_config(config)
81
+ return DEFAULT_KEY unless config.is_a?(Hash)
82
+
83
+ value = config.dig("agent", "type")
84
+ value = config.dig(:agent, :type) if blank?(value)
85
+ blank?(value) ? DEFAULT_KEY : value.to_s
86
+ end
87
+ private_class_method :agent_type_from_config
88
+
89
+ def squash(text)
90
+ text.to_s.strip.gsub(/\s+/, " ")
91
+ end
92
+ private_class_method :squash
93
+
94
+ def blank?(value)
95
+ value.nil? || value.to_s.strip.empty?
96
+ end
97
+ private_class_method :blank?
98
+
99
+ # render(dispatched, harness:, return_contract:) -> the rendered
100
+ # instruction block for `harness` (a String), or nil for an empty dispatch
101
+ # list (matrix row 1.14: a step that dispatched nothing prints no
102
+ # instruction block for a session to act on). `dispatched` is the runner's
103
+ # own plan entries (RunnerDispatch's `:dispatched` shape): [{node:, kind:,
104
+ # role:, model:, worktree:, input:}, ...]. The model and the node input path
105
+ # ride straight from each entry - never a fresh lookup (matrix row 1.11)
106
+ # and never a summary (matrix row 1.12) - and `return_contract` (the exact
107
+ # text RunnerDispatch::RETURN_CONTRACT already carries in the YAML plan)
108
+ # renders exactly once, above every node, never per node (matrix row 1.13).
109
+ def render(dispatched, return_contract:, harness: DEFAULT_KEY)
110
+ entries = Array(dispatched)
111
+ return nil if entries.empty?
112
+
113
+ key = KNOWN_KEYS.include?(harness.to_s) ? harness.to_s : DEFAULT_KEY
114
+ key == "codex" ? render_codex(entries, return_contract) : render_claude_code(entries, return_contract)
115
+ end
116
+
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 node input
119
+ # path as the whole prompt.
120
+ def render_claude_code(entries, return_contract)
121
+ blocks = entries.map do |d|
122
+ "Dispatch #{agent_type_for_kind(d[:kind])} for #{d[:node]} (model: #{d[:model]}):\n prompt: #{d[:input]}"
123
+ end
124
+ "#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
125
+ end
126
+ private_class_method :render_claude_code
127
+
128
+ # The Codex rendering: a Codex node runs end to end through `scripts/
129
+ # node-run` (n6), which reads the ledger's own `running` line directly and
130
+ # never through this printed block - so this names, per node, the same
131
+ # facts the block gives Claude Code (kind, model, node input path) framed as
132
+ # `node-run` calls, proving the seam renders SOMETHING for the second
133
+ # harness rather than nothing (matrix row 1.27), without inventing detail
134
+ # that belongs to n6's own adapter.
135
+ def render_codex(entries, return_contract)
136
+ blocks = entries.map do |d|
137
+ "node-run #{d[:node]} (#{d[:kind]}, model: #{d[:model]}):\n input: #{d[:input]}"
138
+ end
139
+ "#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
140
+ end
141
+ private_class_method :render_codex
142
+
143
+ # cross_harness_resume(entries) -> [{node:, harnesses: [...], commits: {...}}, ...]
144
+ # (spec D12, 340b n8). `entries` is a node ledger's own parsed lines (the
145
+ # shape `NodeLedger.entries`/`entries_from_content` return): every declared
146
+ # node's own `harness=` values, read across every non-torn line for that
147
+ # subject in file order, deduplicated by first appearance (matrix row 8.6:
148
+ # the starting harness is whichever key showed up first). A `reclaimed`
149
+ # line carries no `harness=` of its own and is never a boundary here (row
150
+ # 8.7) - it is simply a line with nothing to contribute, scanned like any
151
+ # other. A node whose lines carry one key, or none at all, reports nothing
152
+ # (rows 8.2, 8.3): an old ledger with no `harness=` field anywhere must
153
+ # stay silent rather than raise.
154
+ #
155
+ # Every node named by ANY entry is scanned (row 8.8), not only the first;
156
+ # `RunnerAbsorb` is what put `harness=` on both the running line and the
157
+ # terminal line, so the commit that lands with the finishing harness's own
158
+ # terminal line is exactly the evidence a resumed node actually left
159
+ # behind (row 8.5).
160
+ def cross_harness_resume(entries)
161
+ live = Array(entries).reject { |e| e[:torn] }
162
+ nodes = live.map { |e| e[:subject] }.uniq
163
+
164
+ nodes.filter_map do |node|
165
+ node_lines = live.select { |e| e[:subject] == node }
166
+ keys = []
167
+ commits = Hash.new { |h, k| h[k] = [] }
168
+
169
+ node_lines.each do |e|
170
+ fields = e[:fields] || {}
171
+ key = fields["harness"]
172
+ next if blank?(key)
173
+
174
+ keys << key unless keys.include?(key)
175
+ commit = fields["commit"]
176
+ commits[key] << commit unless blank?(commit) || commits[key].include?(commit)
177
+ end
178
+
179
+ next if keys.size < 2
180
+
181
+ { node: node, harnesses: keys, commits: keys.to_h { |k| [k, commits[k]] } }
182
+ end
183
+ end
184
+ end
@@ -79,6 +79,18 @@ module HookRegistry
79
79
  { "name" => "message-display", "status" => "" },
80
80
  ] },
81
81
  ],
82
+ # Continuation on Claude Code (intent 340b, G7c, n4, D5/D7): registered
83
+ # statically here, like every other hook, and decided at runtime by
84
+ # StopGate, which reads runner.stop_hook itself (default false, D9).
85
+ # Not one of CODEX_LIVE_STATE_EVENTS, CODEX_POST_HOOKS or
86
+ # CODEX_SESSION_END_HOOKS, so codex_hooks_json never picks it up and
87
+ # codex_hook_names stays at its pinned six (test/hook_registry_test.rb,
88
+ # test/codex_hooks_test.rb).
89
+ "Stop" => [
90
+ { "matcher" => "", "hooks" => [
91
+ { "name" => "stop", "status" => "" },
92
+ ] },
93
+ ],
82
94
  }
83
95
  end
84
96
 
@@ -161,7 +173,7 @@ module HookRegistry
161
173
  # name here in the SAME change. Skip it and every existing install keeps a dead
162
174
  # registration no update will ever clean up.
163
175
  #
164
- # Never fold these into claude_launcher_names: that method is what doctor's
176
+ # Never merge these into claude_launcher_names: that method is what doctor's
165
177
  # hooks_exist demands be present on disk, so a retired name there makes a correct
166
178
  # install report missing launchers.
167
179
  RETIRED_HOOK_NAMES = %w[
@@ -83,22 +83,40 @@ module HookReplay
83
83
  # index/exitstatus/stdout/stderr/final), ordered by index regardless of
84
84
  # the order the threads actually finish in.
85
85
  def replay_concurrent(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay",
86
- message_id: "replay", env: {}, gap_ms: 5, jitter: true)
86
+ message_id: "replay", env: {}, gap_ms: 5, jitter: true, lead_until_engaged: false)
87
87
  chunks = text.scan(/.{1,#{chunk}}/m)
88
88
  chunks = [""] if chunks.empty?
89
89
  full_env = { "PLASTIC_TMP" => tmp_root }.merge(env)
90
90
  gap = gap_ms / 1000.0
91
91
 
92
- results = Array.new(chunks.length)
93
- threads = chunks.each_with_index.map do |delta, i|
94
- payload = {
92
+ payloads = chunks.each_with_index.map do |delta, i|
93
+ {
95
94
  "session_id" => session_id, "message_id" => message_id, "index" => i,
96
95
  "final" => i == chunks.length - 1, "delta" => delta, "cwd" => tmp_root,
97
96
  "hook_event_name" => "MessageDisplay",
98
97
  }
98
+ end
99
+ results = Array.new(chunks.length)
100
+
101
+ # `lead_until_engaged` runs chunks one at a time until one engages, so
102
+ # the decision marker is on disk before the rest fire concurrently. A
103
+ # caller proving "no late passthrough once the decision is in place"
104
+ # then tests exactly that, instead of whether chunk 0's own process
105
+ # booted inside a later chunk's poll budget on a loaded machine.
106
+ lead = 0
107
+ if lead_until_engaged
108
+ payloads.each_with_index do |payload, i|
109
+ out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
110
+ results[i] = { index: i, exitstatus: exitstatus, stdout: out, stderr: err, final: payload["final"] }
111
+ lead = i + 1
112
+ break unless out.to_s.empty?
113
+ end
114
+ end
115
+
116
+ threads = payloads.each_with_index.drop(lead).map do |payload, i|
99
117
  Thread.new do
100
118
  begin
101
- delay = i * gap
119
+ delay = (i - lead) * gap
102
120
  delay += (rand * gap / 2.0) if jitter
103
121
  sleep(delay)
104
122
  out, err, exitstatus = run_one(hook_path, payload, full_env, tmp_root, nil)
@@ -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
@@ -11,7 +11,7 @@ require_relative "atomic_write"
11
11
  # (row 5.1/5.2): a REAL terminal line (Done delivered/abandoned, or a
12
12
  # classifiable Done detail) beats a stale INDEX section. An intent whose
13
13
  # ledger is silent (no terminal line) or absent (no savepoint.md at all)
14
- # keeps the status INDEX already carries (row 5.13, folded at the
14
+ # keeps the status INDEX already carries (row 5.13, merged at the
15
15
  # 2026-09-10 plan review): 63 of 451 intents in the plastic store have no
16
16
  # savepoint.md and 59 more never reach a Done line, and a literal reading
17
17
  # would demote all of them. This module computes and compares only; it
@@ -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,10 +6,12 @@ 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"
12
13
  require_relative "compact_instructions"
14
+ require_relative "engine_permissions"
13
15
 
14
16
  # Shared installer machinery, instantiable with injected package root / store / agent
15
17
  # map so the verb scripts (install/update/uninstall/rollback) and their tests can run
@@ -253,7 +255,10 @@ class InstallerCore
253
255
 
254
256
  # --- Distribution phase ---
255
257
 
256
- 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)
257
262
  puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{plastic_home}"
258
263
 
259
264
  manifest_path = File.join(plastic_home, "manifest.json")
@@ -295,6 +300,8 @@ class InstallerCore
295
300
  write_manifest(global_files, manifest_path)
296
301
 
297
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?
298
305
 
299
306
  puts " \u{2705} Core files synced (v#{version})#{pruned.positive? ? ", #{pruned} stale file(s) pruned" : ""}"
300
307
  end
@@ -371,10 +378,13 @@ class InstallerCore
371
378
  "scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
372
379
  "scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
373
380
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
374
- "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",
375
383
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
376
384
  "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
377
385
  "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
386
+ "scripts/lib/node_input_compatibility.rb" => "scripts/lib/node_input_compatibility.rb",
387
+ "scripts/lib/node_progress.rb" => "scripts/lib/node_progress.rb",
378
388
  "scripts/node-transition" => "scripts/node-transition",
379
389
  "scripts/lib/arm.rb" => "scripts/lib/arm.rb",
380
390
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
@@ -511,11 +521,11 @@ class InstallerCore
511
521
  "scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
512
522
  "scripts/roadmap-graph" => "scripts/roadmap-graph",
513
523
  "scripts/index-projection" => "scripts/index-projection",
514
- # Intent 338 (G5): the node packet command - the trust-boundary wrapper,
524
+ # Intent 338 (G5): the node input command - the trust-boundary wrapper,
515
525
  # the five-block gatherer/assembler, and the CLI 340's runner calls.
516
- "scripts/lib/packet_wrapper.rb" => "scripts/lib/packet_wrapper.rb",
517
- "scripts/lib/node_packet.rb" => "scripts/lib/node_packet.rb",
518
- "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",
519
529
  # Intent 342 (G9): the backward shim that presents actions/*.md as a
520
530
  # node graph for any legacy intent, so WorkGraphValidator can require
521
531
  # it without going red on contact with install_sync_test.
@@ -545,6 +555,26 @@ class InstallerCore
545
555
  # by scripts/runner's `step`.
546
556
  "scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
547
557
  "scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
558
+ # Intent 340b (G7c, n1): the harness seam - HarnessAdapter, required
559
+ # by scripts/lib/runner_dispatch.rb and by scripts/runner's `step`
560
+ # directly.
561
+ "scripts/lib/harness_adapter.rb" => "scripts/lib/harness_adapter.rb",
562
+ # Intent 340b (G7c, n6): the Codex leg - CodexAdapter (the `codex exec`
563
+ # argv, the sandbox per kind, and the bounded subprocess) and
564
+ # scripts/node-run, the CLI that runs one node's whole attempt over
565
+ # it and writes only a return file, never a ledger transition.
566
+ "scripts/lib/codex_adapter.rb" => "scripts/lib/codex_adapter.rb",
567
+ "scripts/node-run" => "scripts/node-run",
568
+ # Intent 340b (G7c, n3): the engine deny rule - the frozen permissions.deny
569
+ # entry list, merged into settings.json at install and removed surgically
570
+ # at uninstall.
571
+ "scripts/lib/engine_permissions.rb" => "scripts/lib/engine_permissions.rb",
572
+ # Intent 340b (G7c, n4): the Stop gate, the shared ActiveDelivery walk
573
+ # it and the PreCompact hand-off both call, and hook-stop, the Stop
574
+ # hook body scripts/hook-stop's launcher (hooks/stop) relays into.
575
+ "scripts/lib/stop_gate.rb" => "scripts/lib/stop_gate.rb",
576
+ "scripts/lib/active_delivery.rb" => "scripts/lib/active_delivery.rb",
577
+ "scripts/hook-stop" => "scripts/hook-stop",
548
578
  # Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
549
579
  # is its cap table, above); its launcher (hooks/call-budget) ships via
550
580
  # hook_files' own glob, so only the hook script itself needs an entry.
@@ -559,6 +589,35 @@ class InstallerCore
559
589
  "scripts/lib/runner_answer.rb" => "scripts/lib/runner_answer.rb",
560
590
  "scripts/lib/runner_proposals.rb" => "scripts/lib/runner_proposals.rb",
561
591
  "scripts/lib/runner_rewind.rb" => "scripts/lib/runner_rewind.rb",
592
+ # Intent 343 (G10, n1): the ledger reader - GraphMeasure.read turns one
593
+ # intent directory into one frozen measurement record. No CLI yet
594
+ # (scripts/graph-measure ships at n2); registered here so it is never
595
+ # a lib that exists on disk but never reaches an install.
596
+ "scripts/lib/graph_measure.rb" => "scripts/lib/graph_measure.rb",
597
+ # Intent 343 (G10, n2): the graph-measure command - the subcommand
598
+ # table over the ledger reader, and the two renderers (text, JSON)
599
+ # over the one record it returns.
600
+ "scripts/graph-measure" => "scripts/graph-measure",
601
+ "scripts/lib/graph_measure_report.rb" => "scripts/lib/graph_measure_report.rb",
602
+ # Intent 343 (G10, n4): the `budget` verb - whether the `budget:` a
603
+ # node's envelope declares (327 C19) is a ceiling that ever actually
604
+ # held, read from the ledger and the real input files.
605
+ "scripts/lib/graph_measure_budget.rb" => "scripts/lib/graph_measure_budget.rb",
606
+ # Intent 343 (G10, n5): the `cohorts` verb's model section - the store
607
+ # walk and whether a recorded model= still matches what config
608
+ # resolves today through RunnerPolicy.
609
+ "scripts/lib/graph_measure_models.rb" => "scripts/lib/graph_measure_models.rb",
610
+ # Intent 343 (G10, n6): the rest of the `cohorts` verb - approve-then-
611
+ # fix per verify model, hop on versus off, delivery latency, the
612
+ # evidence bar, and the two concurrency ceilings.
613
+ "scripts/lib/graph_measure_cohorts.rb" => "scripts/lib/graph_measure_cohorts.rb",
614
+ # Intent 340b (G7c, n7): the Codex loop - composes `step` and
615
+ # `node-run` itself (concurrency two, serial absorb, iteration-capped),
616
+ # routed from scripts/runner's internal `until-empty` verb.
617
+ "scripts/lib/runner_until_empty.rb" => "scripts/lib/runner_until_empty.rb",
618
+ # Intent 340a (G7b, n1): the delivery watch - one tick over disk truth,
619
+ # stalled and done-unreported classification, no CLI and no dispatch.
620
+ "scripts/lib/runner_watch.rb" => "scripts/lib/runner_watch.rb",
562
621
  }
563
622
  end
564
623
 
@@ -834,6 +893,111 @@ class InstallerCore
834
893
  removed
835
894
  end
836
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
+
837
1001
  # True when `path`, once expanded, is one of `roots` itself or lives underneath
838
1002
  # one of them. String-prefix containment guarded by a trailing separator so a
839
1003
  # sibling directory that merely shares a prefix (`/home/x-evil` vs root `/home/x`)
@@ -932,6 +1096,11 @@ class InstallerCore
932
1096
  choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
933
1097
  merge_claude_hooks(settings_path, choice: choice)
934
1098
 
1099
+ # The engine deny rule (intent 340b, G7c, n3, D4): a second ownership
1100
+ # mechanism from the hook merge above, so it is its own call rather than a
1101
+ # branch inside merge_claude_hooks.
1102
+ merge_engine_permissions(settings_path)
1103
+
935
1104
  # Instruction injection (intent 312): the compact-instructions block into
936
1105
  # ~/.claude/CLAUDE.md. A partial-ownership user file, so it is NOT manifest-tracked
937
1106
  # (stripped surgically on uninstall), the same treatment ~/.codex/AGENTS.md gets.
@@ -1485,6 +1654,44 @@ class InstallerCore
1485
1654
  removed
1486
1655
  end
1487
1656
 
1657
+ # --- The engine deny rule (intent 340b, G7c, n3) ---
1658
+ #
1659
+ # A second ownership mechanism from merge_claude_hooks above (D4, node n3): a
1660
+ # permissions.deny entry is a bare string with no marker in it, so EnginePermissions
1661
+ # owns its four entries by exact-string membership, not by a plastic- launcher
1662
+ # basename. This pair only does the read-modify-write; EnginePermissions.merge_into
1663
+ # and .remove_from are the pure transforms.
1664
+
1665
+ # Merges EnginePermissions::ENTRIES into settings.json. Unlike merge_claude_hooks,
1666
+ # this refuses rather than starting from {} when the existing file cannot be
1667
+ # parsed (row 3.11): a hand-edited settings file is never silently replaced.
1668
+ # Returns true when it wrote, false when it refused.
1669
+ def merge_engine_permissions(settings_path)
1670
+ if File.exist?(settings_path)
1671
+ settings = read_json_safe(settings_path)
1672
+ return false if settings.nil?
1673
+ else
1674
+ settings = {}
1675
+ end
1676
+
1677
+ write_json_atomic(settings_path, EnginePermissions.merge_into(settings))
1678
+ true
1679
+ end
1680
+
1681
+ # Removes exactly EnginePermissions::ENTRIES from settings.json, leaving every
1682
+ # other deny entry (the owner's own, and any Plastic entry the owner has since
1683
+ # edited) in place. Returns true when it wrote, false on a missing or
1684
+ # unparseable file, or a permissions/deny shape it does not recognize.
1685
+ def remove_engine_permissions(settings_path)
1686
+ return false unless File.exist?(settings_path)
1687
+
1688
+ settings = read_json_safe(settings_path)
1689
+ return false unless settings.is_a?(Hash)
1690
+
1691
+ write_json_atomic(settings_path, EnginePermissions.remove_from(settings))
1692
+ true
1693
+ end
1694
+
1488
1695
  # --- Codex AGENTS.md marked-section injection (22a/Beads pattern) ---
1489
1696
  # New primitive: markdown marked-section merge, the analog of merge_claude_hooks'
1490
1697
  # JSON read-modify-write for a partial-ownership text file. Three states
@@ -1674,6 +1881,7 @@ class InstallerCore
1674
1881
  if key == "claude"
1675
1882
  settings_path = File.join(config[:dir], "settings.json")
1676
1883
  remove_claude_hooks(settings_path) if File.exist?(settings_path)
1884
+ remove_engine_permissions(settings_path) if File.exist?(settings_path)
1677
1885
  removed.concat(migrate_legacy_plugin(config[:dir]))
1678
1886
 
1679
1887
  # The compact-instructions block in the user-owned CLAUDE.md (intent 312): a
@@ -1822,11 +2030,22 @@ class InstallerCore
1822
2030
 
1823
2031
  def read_json_safe(path)
1824
2032
  return nil unless File.exist?(path)
2033
+
1825
2034
  JSON.parse(File.read(path))
1826
2035
  rescue JSON::ParserError
1827
- # Try JSONC stripping (remove // comments and trailing commas)
1828
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
1829
- JSON.parse(content)
2036
+ # The comment/trailing-comma-stripped retry below can itself raise
2037
+ # JSON::ParserError on genuinely malformed content (a truncated file, or
2038
+ # plain garbage). A nested begin/rescue is required here because a
2039
+ # method-level `rescue` clause never catches an exception raised from
2040
+ # INSIDE a sibling rescue clause's own body (only from the main body);
2041
+ # doctor_core.rb#read_json_safe carries the same fix for the same reason
2042
+ # (intent 331e, F5).
2043
+ begin
2044
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
2045
+ JSON.parse(content)
2046
+ rescue
2047
+ nil
2048
+ end
1830
2049
  rescue
1831
2050
  nil
1832
2051
  end
@@ -287,7 +287,7 @@ module IntentScreen
287
287
  # Returns [clause_without_terminal_punctuation, remainder_or_nil]. `nil` for
288
288
  # the remainder means either no boundary exists at all, or the boundary
289
289
  # sits at the absolute end of `text` (a single trailing clause with nothing
290
- # after it) — both cases where there is no SECOND clause to fold in.
290
+ # after it) — both cases where there is no SECOND clause to merge in.
291
291
  def self.clause_and_rest(text)
292
292
  m = text.match(/\A(.+?)[.;](\s+(.*)|\z)/m)
293
293
  return [text, nil] unless m