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

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/agents/plastic-enforcer.md +6 -3
  2. package/agents/plastic-executor.md +4 -0
  3. package/agents/plastic-node-research.md +28 -0
  4. package/agents/plastic-node-verify.md +27 -0
  5. package/agents/plastic-node-work.md +32 -0
  6. package/bin/lib/context_budget.rb +1 -1
  7. package/hooks/hooks.json +12 -0
  8. package/hooks/statusline +28 -0
  9. package/hooks/stop +5 -0
  10. package/package.json +1 -1
  11. package/scripts/doctor.rb +80 -6
  12. package/scripts/end-intent +3 -3
  13. package/scripts/graph-measure +249 -0
  14. package/scripts/hook-capture +1 -0
  15. package/scripts/hook-savepoint +24 -2
  16. package/scripts/hook-session-start +40 -0
  17. package/scripts/hook-stop +57 -0
  18. package/scripts/lib/active_delivery.rb +61 -0
  19. package/scripts/lib/agent_models.rb +10 -1
  20. package/scripts/lib/codex_adapter.rb +197 -0
  21. package/scripts/lib/doctor_core.rb +8 -3
  22. package/scripts/lib/engine_permissions.rb +88 -0
  23. package/scripts/lib/graph_edges.rb +4 -4
  24. package/scripts/lib/graph_file.rb +4 -4
  25. package/scripts/lib/graph_measure.rb +645 -0
  26. package/scripts/lib/graph_measure_budget.rb +408 -0
  27. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  28. package/scripts/lib/graph_measure_models.rb +411 -0
  29. package/scripts/lib/graph_measure_report.rb +532 -0
  30. package/scripts/lib/graph_tree.rb +2 -2
  31. package/scripts/lib/handoff.rb +36 -5
  32. package/scripts/lib/harness_adapter.rb +184 -0
  33. package/scripts/lib/hook_registry.rb +13 -1
  34. package/scripts/lib/hook_replay.rb +23 -5
  35. package/scripts/lib/index_projection.rb +1 -1
  36. package/scripts/lib/installer_core.rb +109 -3
  37. package/scripts/lib/intent_screen.rb +1 -1
  38. package/scripts/lib/intent_validator.rb +2 -2
  39. package/scripts/lib/meter_watch.rb +15 -9
  40. package/scripts/lib/node_file.rb +3 -3
  41. package/scripts/lib/node_ledger.rb +8 -1
  42. package/scripts/lib/node_progress.rb +153 -0
  43. package/scripts/lib/outcome_report.rb +1 -1
  44. package/scripts/lib/report_screen.rb +10 -6
  45. package/scripts/lib/roadmap_graph.rb +1 -1
  46. package/scripts/lib/roadmap_queue.rb +1 -1
  47. package/scripts/lib/roadmap_render.rb +1 -1
  48. package/scripts/lib/runner_absorb.rb +31 -5
  49. package/scripts/lib/runner_dispatch.rb +26 -11
  50. package/scripts/lib/runner_until_empty.rb +252 -0
  51. package/scripts/lib/runner_watch.rb +389 -0
  52. package/scripts/lib/savepoint.rb +3 -3
  53. package/scripts/lib/session_git.rb +2 -2
  54. package/scripts/lib/stop_gate.rb +95 -0
  55. package/scripts/lib/verify_intent.rb +2 -2
  56. package/scripts/lib/work_graph_validator.rb +6 -6
  57. package/scripts/new-intent +1 -1
  58. package/scripts/node-run +224 -0
  59. package/scripts/read-config +6 -0
  60. package/scripts/runner +203 -19
  61. package/scripts/verify-intent +1 -1
  62. package/skills/auto/SKILL.md +1 -1
  63. package/skills/conventions/references/knowledge-graph.md +9 -0
  64. package/skills/doctor/SKILL.md +3 -3
  65. package/skills/intent-creating/evals/evals.json +1 -1
  66. package/skills/intent-executing/SKILL.md +6 -0
  67. package/skills/tutorial/references/track-2-auto.md +1 -1
@@ -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 packet 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:, packet:}, ...]. The model and the packet 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 packet
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[:packet]}"
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, packet 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 packet: #{d[:packet]}"
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)
@@ -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
@@ -10,6 +10,7 @@ require_relative "hook_registry"
10
10
  require_relative "agent_models"
11
11
  require_relative "harness_text"
12
12
  require_relative "compact_instructions"
13
+ require_relative "engine_permissions"
13
14
 
14
15
  # Shared installer machinery, instantiable with injected package root / store / agent
15
16
  # map so the verb scripts (install/update/uninstall/rollback) and their tests can run
@@ -375,6 +376,7 @@ class InstallerCore
375
376
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
376
377
  "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
377
378
  "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
379
+ "scripts/lib/node_progress.rb" => "scripts/lib/node_progress.rb",
378
380
  "scripts/node-transition" => "scripts/node-transition",
379
381
  "scripts/lib/arm.rb" => "scripts/lib/arm.rb",
380
382
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
@@ -545,6 +547,26 @@ class InstallerCore
545
547
  # by scripts/runner's `step`.
546
548
  "scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
547
549
  "scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
550
+ # Intent 340b (G7c, n1): the harness seam - HarnessAdapter, required
551
+ # by scripts/lib/runner_dispatch.rb and by scripts/runner's `step`
552
+ # directly.
553
+ "scripts/lib/harness_adapter.rb" => "scripts/lib/harness_adapter.rb",
554
+ # Intent 340b (G7c, n6): the Codex leg - CodexAdapter (the `codex exec`
555
+ # argv, the sandbox per kind, and the bounded subprocess) and
556
+ # scripts/node-run, the CLI that runs one node's whole attempt over
557
+ # it and writes only a return file, never a ledger transition.
558
+ "scripts/lib/codex_adapter.rb" => "scripts/lib/codex_adapter.rb",
559
+ "scripts/node-run" => "scripts/node-run",
560
+ # Intent 340b (G7c, n3): the engine deny rule - the frozen permissions.deny
561
+ # entry list, merged into settings.json at install and removed surgically
562
+ # at uninstall.
563
+ "scripts/lib/engine_permissions.rb" => "scripts/lib/engine_permissions.rb",
564
+ # Intent 340b (G7c, n4): the Stop gate, the shared ActiveDelivery walk
565
+ # it and the PreCompact hand-off both call, and hook-stop, the Stop
566
+ # hook body scripts/hook-stop's launcher (hooks/stop) relays into.
567
+ "scripts/lib/stop_gate.rb" => "scripts/lib/stop_gate.rb",
568
+ "scripts/lib/active_delivery.rb" => "scripts/lib/active_delivery.rb",
569
+ "scripts/hook-stop" => "scripts/hook-stop",
548
570
  # Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
549
571
  # is its cap table, above); its launcher (hooks/call-budget) ships via
550
572
  # hook_files' own glob, so only the hook script itself needs an entry.
@@ -559,6 +581,35 @@ class InstallerCore
559
581
  "scripts/lib/runner_answer.rb" => "scripts/lib/runner_answer.rb",
560
582
  "scripts/lib/runner_proposals.rb" => "scripts/lib/runner_proposals.rb",
561
583
  "scripts/lib/runner_rewind.rb" => "scripts/lib/runner_rewind.rb",
584
+ # Intent 343 (G10, n1): the ledger reader - GraphMeasure.read turns one
585
+ # intent directory into one frozen measurement record. No CLI yet
586
+ # (scripts/graph-measure ships at n2); registered here so it is never
587
+ # a lib that exists on disk but never reaches an install.
588
+ "scripts/lib/graph_measure.rb" => "scripts/lib/graph_measure.rb",
589
+ # Intent 343 (G10, n2): the graph-measure command - the subcommand
590
+ # table over the ledger reader, and the two renderers (text, JSON)
591
+ # over the one record it returns.
592
+ "scripts/graph-measure" => "scripts/graph-measure",
593
+ "scripts/lib/graph_measure_report.rb" => "scripts/lib/graph_measure_report.rb",
594
+ # Intent 343 (G10, n4): the `budget` verb - whether the `budget:` a
595
+ # node's envelope declares (327 C19) is a ceiling that ever actually
596
+ # held, read from the ledger and the real packet files.
597
+ "scripts/lib/graph_measure_budget.rb" => "scripts/lib/graph_measure_budget.rb",
598
+ # Intent 343 (G10, n5): the `cohorts` verb's model section - the store
599
+ # walk and whether a recorded model= still matches what config
600
+ # resolves today through RunnerPolicy.
601
+ "scripts/lib/graph_measure_models.rb" => "scripts/lib/graph_measure_models.rb",
602
+ # Intent 343 (G10, n6): the rest of the `cohorts` verb - approve-then-
603
+ # fix per verify model, hop on versus off, delivery latency, the
604
+ # evidence bar, and the two concurrency ceilings.
605
+ "scripts/lib/graph_measure_cohorts.rb" => "scripts/lib/graph_measure_cohorts.rb",
606
+ # Intent 340b (G7c, n7): the Codex loop - composes `step` and
607
+ # `node-run` itself (concurrency two, serial absorb, iteration-capped),
608
+ # routed from scripts/runner's internal `until-empty` verb.
609
+ "scripts/lib/runner_until_empty.rb" => "scripts/lib/runner_until_empty.rb",
610
+ # Intent 340a (G7b, n1): the delivery watch - one tick over disk truth,
611
+ # stalled and done-unreported classification, no CLI and no dispatch.
612
+ "scripts/lib/runner_watch.rb" => "scripts/lib/runner_watch.rb",
562
613
  }
563
614
  end
564
615
 
@@ -932,6 +983,11 @@ class InstallerCore
932
983
  choice = statusline_choice(settings_path, argv: argv, input: input, reinstall: reinstall)
933
984
  merge_claude_hooks(settings_path, choice: choice)
934
985
 
986
+ # The engine deny rule (intent 340b, G7c, n3, D4): a second ownership
987
+ # mechanism from the hook merge above, so it is its own call rather than a
988
+ # branch inside merge_claude_hooks.
989
+ merge_engine_permissions(settings_path)
990
+
935
991
  # Instruction injection (intent 312): the compact-instructions block into
936
992
  # ~/.claude/CLAUDE.md. A partial-ownership user file, so it is NOT manifest-tracked
937
993
  # (stripped surgically on uninstall), the same treatment ~/.codex/AGENTS.md gets.
@@ -1485,6 +1541,44 @@ class InstallerCore
1485
1541
  removed
1486
1542
  end
1487
1543
 
1544
+ # --- The engine deny rule (intent 340b, G7c, n3) ---
1545
+ #
1546
+ # A second ownership mechanism from merge_claude_hooks above (D4, node n3): a
1547
+ # permissions.deny entry is a bare string with no marker in it, so EnginePermissions
1548
+ # owns its four entries by exact-string membership, not by a plastic- launcher
1549
+ # basename. This pair only does the read-modify-write; EnginePermissions.merge_into
1550
+ # and .remove_from are the pure transforms.
1551
+
1552
+ # Merges EnginePermissions::ENTRIES into settings.json. Unlike merge_claude_hooks,
1553
+ # this refuses rather than starting from {} when the existing file cannot be
1554
+ # parsed (row 3.11): a hand-edited settings file is never silently replaced.
1555
+ # Returns true when it wrote, false when it refused.
1556
+ def merge_engine_permissions(settings_path)
1557
+ if File.exist?(settings_path)
1558
+ settings = read_json_safe(settings_path)
1559
+ return false if settings.nil?
1560
+ else
1561
+ settings = {}
1562
+ end
1563
+
1564
+ write_json_atomic(settings_path, EnginePermissions.merge_into(settings))
1565
+ true
1566
+ end
1567
+
1568
+ # Removes exactly EnginePermissions::ENTRIES from settings.json, leaving every
1569
+ # other deny entry (the owner's own, and any Plastic entry the owner has since
1570
+ # edited) in place. Returns true when it wrote, false on a missing or
1571
+ # unparseable file, or a permissions/deny shape it does not recognize.
1572
+ def remove_engine_permissions(settings_path)
1573
+ return false unless File.exist?(settings_path)
1574
+
1575
+ settings = read_json_safe(settings_path)
1576
+ return false unless settings.is_a?(Hash)
1577
+
1578
+ write_json_atomic(settings_path, EnginePermissions.remove_from(settings))
1579
+ true
1580
+ end
1581
+
1488
1582
  # --- Codex AGENTS.md marked-section injection (22a/Beads pattern) ---
1489
1583
  # New primitive: markdown marked-section merge, the analog of merge_claude_hooks'
1490
1584
  # JSON read-modify-write for a partial-ownership text file. Three states
@@ -1674,6 +1768,7 @@ class InstallerCore
1674
1768
  if key == "claude"
1675
1769
  settings_path = File.join(config[:dir], "settings.json")
1676
1770
  remove_claude_hooks(settings_path) if File.exist?(settings_path)
1771
+ remove_engine_permissions(settings_path) if File.exist?(settings_path)
1677
1772
  removed.concat(migrate_legacy_plugin(config[:dir]))
1678
1773
 
1679
1774
  # The compact-instructions block in the user-owned CLAUDE.md (intent 312): a
@@ -1822,11 +1917,22 @@ class InstallerCore
1822
1917
 
1823
1918
  def read_json_safe(path)
1824
1919
  return nil unless File.exist?(path)
1920
+
1825
1921
  JSON.parse(File.read(path))
1826
1922
  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)
1923
+ # The comment/trailing-comma-stripped retry below can itself raise
1924
+ # JSON::ParserError on genuinely malformed content (a truncated file, or
1925
+ # plain garbage). A nested begin/rescue is required here because a
1926
+ # method-level `rescue` clause never catches an exception raised from
1927
+ # INSIDE a sibling rescue clause's own body (only from the main body);
1928
+ # doctor_core.rb#read_json_safe carries the same fix for the same reason
1929
+ # (intent 331e, F5).
1930
+ begin
1931
+ content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
1932
+ JSON.parse(content)
1933
+ rescue
1934
+ nil
1935
+ end
1830
1936
  rescue
1831
1937
  nil
1832
1938
  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
@@ -149,7 +149,7 @@ module IntentValidator
149
149
 
150
150
  # PURE: combine the frontmatter result with section-structure findings for a
151
151
  # content STRING. Returns the frontmatter result hash extended with
152
- # :section_missing, :section_unknown, and folded section errors; :ok is the AND
152
+ # :section_missing, :section_unknown, and merged section errors; :ok is the AND
153
153
  # of frontmatter and sections. Lets the create gate validate proposed content
154
154
  # (no file on disk) with the same definition as the CLI and doctor.
155
155
  def validate_content(content, known_stores: nil)
@@ -158,7 +158,7 @@ module IntentValidator
158
158
  merge_sections(fm_result, sections)
159
159
  end
160
160
 
161
- # Fold section findings into a frontmatter result hash (shared by validate and
161
+ # Merge section findings into a frontmatter result hash (shared by validate and
162
162
  # validate_content). Does not mutate the input.
163
163
  def merge_sections(fm_result, sections)
164
164
  errors = fm_result[:errors].dup
@@ -142,30 +142,36 @@ class MeterWatch
142
142
  # with launchctl (scripts/meter-watch --install-timer calls this and
143
143
  # nothing else). RunAtLoad primes the first tick; StartInterval repeats
144
144
  # it every 20 minutes.
145
- def install_timer(home:, script_path:, ruby: RbConfig.ruby, interval: TICK_SECONDS)
145
+ #
146
+ # `label:` and `arguments:` (intent 340a, G7b, n3, graph.md D9) let the
147
+ # delivery watch's own `runner watch --install-timer` reuse this one
148
+ # writer for its `com.plastic.delivery-watch.<id>` job instead of
149
+ # rendering plist XML a second time; their defaults reproduce today's
150
+ # meter-watch plist byte for byte (row 3.1).
151
+ def install_timer(home:, script_path:, ruby: RbConfig.ruby, interval: TICK_SECONDS,
152
+ label: "com.plastic.meter-watch", arguments: nil)
146
153
  agents_dir = File.join(home, "Library", "LaunchAgents")
147
154
  FileUtils.mkdir_p(agents_dir)
148
- plist_path = File.join(agents_dir, "com.plastic.meter-watch.plist")
149
- File.write(plist_path, plist(script_path, home, ruby, interval))
155
+ plist_path = File.join(agents_dir, "#{label}.plist")
156
+ args = arguments || [ruby, script_path, "--home", home]
157
+ File.write(plist_path, plist(label, args, interval))
150
158
  plist_path
151
159
  end
152
160
 
153
161
  private
154
162
 
155
- def plist(script_path, home, ruby, interval)
163
+ def plist(label, arguments, interval)
164
+ args_xml = arguments.map { |a| " <string>#{a}</string>" }.join("\n")
156
165
  <<~XML
157
166
  <?xml version="1.0" encoding="UTF-8"?>
158
167
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
159
168
  <plist version="1.0">
160
169
  <dict>
161
170
  <key>Label</key>
162
- <string>com.plastic.meter-watch</string>
171
+ <string>#{label}</string>
163
172
  <key>ProgramArguments</key>
164
173
  <array>
165
- <string>#{ruby}</string>
166
- <string>#{script_path}</string>
167
- <string>--home</string>
168
- <string>#{home}</string>
174
+ #{args_xml}
169
175
  </array>
170
176
  <key>StartInterval</key>
171
177
  <integer>#{interval}</integer>
@@ -102,14 +102,14 @@ module NodeFile
102
102
  # <id>.md or <id>--<slug>.md, exactly - a longer id's file (n11--x.md) must
103
103
  # never satisfy a shorter id (n1), so the id is matched as the whole prefix
104
104
  # up to end-of-string or the literal "--" separator, never as a substring
105
- # (fold A9).
105
+ # (review A9).
106
106
  def filename_matches_id?(basename, id)
107
107
  basename.match?(/\A#{Regexp.escape(id)}(--[A-Za-z0-9][A-Za-z0-9-]*)?\z/)
108
108
  end
109
109
 
110
110
  # budget: normalizes to a plain integer token ceiling. A bare integer and
111
111
  # {tokens: N} both normalize to N; {turns: N} is the field 327 D47 removed
112
- # and is a named error, never silently accepted (fold: report's example).
112
+ # and is a named error, never silently accepted (from report's example).
113
113
  def normalize_budget(raw)
114
114
  return [nil, []] if raw.nil?
115
115
  return [raw, []] if raw.is_a?(Integer)
@@ -128,7 +128,7 @@ module NodeFile
128
128
 
129
129
  # Every [heading_line, body] pair in the body text, split on any heading
130
130
  # line, fence-aware: a "#" line inside a fenced block never starts a new
131
- # section (fold: "a node passes on a section it does not have").
131
+ # section (see: "a node passes on a section it does not have").
132
132
  def split_by_headings(text)
133
133
  sections = []
134
134
  heading = nil
@@ -61,7 +61,14 @@ module NodeLedger
61
61
  # 2.6): every key named in REQUIRED_FIELDS or DONE_EVIDENCE_FIELDS, plus
62
62
  # `model` (accepted on any state). A field outside this list still renders,
63
63
  # sorted after these, so an unrecognized key is never silently dropped.
64
- FIELD_ORDER = %w[holder expires packet model gates commit verdict reason question by expired].freeze
64
+ #
65
+ # `harness` (intent 340b, G7c, n1, D21) sits right after `model`: both are
66
+ # dispatch-time metadata `running` writes and every terminal transition
67
+ # carries forward, accepted on every state and required by none - the same
68
+ # treatment `suite=`, `hop=` and `core_drift=` already get as extras, given
69
+ # a stable declared position instead of falling to the alphabetical extras
70
+ # tail (matrix row 1.17).
71
+ FIELD_ORDER = %w[holder expires packet model harness gates commit verdict reason question by expired].freeze
65
72
 
66
73
  SEPARATOR = " "
67
74