@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.
- package/agents/plastic-enforcer.md +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +3 -3
- package/scripts/graph-measure +249 -0
- package/scripts/hook-capture +1 -0
- package/scripts/hook-savepoint +24 -2
- package/scripts/hook-session-start +40 -0
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +61 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +408 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +36 -5
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/installer_core.rb +109 -3
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/node_ledger.rb +8 -1
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/runner_absorb.rb +31 -5
- package/scripts/lib/runner_dispatch.rb +26 -11
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +3 -3
- package/scripts/lib/session_git.rb +2 -2
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/new-intent +1 -1
- package/scripts/node-run +224 -0
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +1 -1
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/doctor/SKILL.md +3 -3
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +6 -0
- 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
|
|
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
|
-
|
|
93
|
-
|
|
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,
|
|
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
|
-
#
|
|
1828
|
-
content
|
|
1829
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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, "
|
|
149
|
-
|
|
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(
|
|
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
|
|
171
|
+
<string>#{label}</string>
|
|
163
172
|
<key>ProgramArguments</key>
|
|
164
173
|
<array>
|
|
165
|
-
|
|
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>
|
package/scripts/lib/node_file.rb
CHANGED
|
@@ -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
|
-
# (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
|