@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
@@ -15,12 +15,31 @@
15
15
  require "json"
16
16
  require_relative "lib/session_ledger"
17
17
  require_relative "lib/handoff"
18
+ require_relative "lib/active_delivery"
18
19
 
19
20
  MESSAGE = "PLASTIC SAVEPOINT - context is being compacted. The hand-off for this " \
20
21
  "session is written in today's day ledger " \
21
22
  "(store/.sessions/<day>/handoff--<session>.md). " \
22
23
  "After compaction say continue to resume from it."
23
24
 
25
+ # The intent this session is delivering, resolved through the same walk the
26
+ # Stop gate uses (row 4.38): read-config's project_roots, plus the global
27
+ # store, glob for a live delivery.lock this session holds. Never raises
28
+ # (ActiveDelivery.resolve rescues its own reads); a lookup failure just
29
+ # means the Runner section is omitted, never that the hand-off is lost.
30
+ def resolve_intent(plastic_home, session_id, read_config_path)
31
+ return nil if session_id.strip.empty?
32
+
33
+ global_store = File.join(plastic_home, "store")
34
+ roots_raw = IO.popen({ "PLASTIC_HOME" => plastic_home }, [read_config_path, "project_roots"],
35
+ err: File::NULL, &:read).to_s.strip
36
+ project_roots = roots_raw.empty? ? [] : Array(JSON.parse(roots_raw))
37
+ project_roots = project_roots.map { |root| File.expand_path(root.to_s) }
38
+ ActiveDelivery.resolve(global_store: global_store, project_roots: project_roots, session: session_id)
39
+ rescue StandardError
40
+ nil
41
+ end
42
+
24
43
  written = nil
25
44
  begin
26
45
  plastic_home = ARGV[0].to_s
@@ -30,11 +49,14 @@ begin
30
49
  session_id = payload["session_id"].to_s
31
50
 
32
51
  unless plastic_home.empty? || session_id.strip.empty?
33
- store = File.join(File.expand_path(plastic_home), "store")
52
+ home = File.expand_path(plastic_home)
53
+ store = File.join(home, "store")
34
54
  session = SessionLedger.short_session_id(session_id, nil)
35
55
  day = Handoff.day_for(store, session, today: SessionLedger.day_id)
56
+ read_config_path = File.expand_path("read-config", __dir__)
57
+ intent = resolve_intent(home, session_id, read_config_path)
36
58
  written = Handoff.write(store: store, day: day, session: session, trigger: "precompact",
37
- templates: File.expand_path("../templates", __dir__))
59
+ templates: File.expand_path("../templates", __dir__), intent: intent)
38
60
  end
39
61
  rescue StandardError, JSON::ParserError
40
62
  nil
@@ -14,6 +14,9 @@ require_relative "lib/doctor_core"
14
14
  require_relative "lib/session_ledger"
15
15
  require_relative "lib/day_summary"
16
16
  require_relative "lib/packet_wrapper"
17
+ require_relative "lib/active_delivery"
18
+ require_relative "lib/runner_core"
19
+ require_relative "lib/runner_watch"
17
20
 
18
21
  index_path, plastic_home, mode, plugin_root = ARGV
19
22
  exit 0 unless index_path && plastic_home && mode
@@ -246,6 +249,43 @@ begin
246
249
  # Any failure (timeout, missing binary, parse error) — stay silent, never crash.
247
250
  end unless subagent_session
248
251
 
252
+ # --- Delivery watch (intent 340a, G7b, n3, graph.md D10) ---
253
+ # One unrecorded tick (record: false: reclaim and classify, no snapshot,
254
+ # no record line, so boot never advances a quiet count) per intent
255
+ # ActiveDelivery.candidate_intent_dirs finds, naming every stalled or
256
+ # done_unreported one in a single line, nothing when there are none. The
257
+ # whole block is one guarded unit, like the QMD block above it: a raise or
258
+ # a hang on any one candidate must add nothing rather than name only the
259
+ # candidates seen before the failure, so the line a boot does print is
260
+ # never a partial one.
261
+ begin
262
+ Timeout.timeout(2) do
263
+ roots_raw = IO.popen({ "PLASTIC_HOME" => plastic_home }, [read_config, "project_roots"],
264
+ err: File::NULL, &:read).to_s.strip
265
+ project_roots = roots_raw.empty? ? [] : Array(JSON.parse(roots_raw))
266
+ project_roots = project_roots.map { |root| File.expand_path(root.to_s) }
267
+
268
+ attention = []
269
+ ActiveDelivery.candidate_intent_dirs(global_store: store_dir, project_roots: project_roots).uniq.each do |dir|
270
+ watch_context = RunnerCore.context(intent_dir: dir)
271
+ result = RunnerWatch.tick(watch_context, record: false)
272
+ next unless %w[stalled done_unreported].include?(result[:class])
273
+
274
+ watch_intent_id = File.basename(dir).split("--").first
275
+ if result[:class] == "stalled"
276
+ blocker = Array(result[:blockers]).first
277
+ attention << (blocker ? "#{watch_intent_id} stalled (#{blocker})" : "#{watch_intent_id} stalled")
278
+ else
279
+ attention << "#{watch_intent_id} done_unreported"
280
+ end
281
+ end
282
+ parts << "PLASTIC watch: #{attention.join(', ')}" if attention.any?
283
+ end
284
+ rescue Exception
285
+ # Any failure or timeout (a malformed intent directory, a hung tick) -
286
+ # stay silent, never crash boot.
287
+ end unless subagent_session
288
+
249
289
  # --- Project (or global) banner with its active intent ---
250
290
  # The one piece of intent context a live boot still carries: which store
251
291
  # is live, and which intent (if any) is active in it (intent 341, G8, D4).
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # hook-stop (intent 340b, G7c, n4, D5): the Stop hook body. Reads the harness
6
+ # payload from stdin, gathers the runtime config StopGate needs, and asks it
7
+ # whether this session may stop. Prints Claude Code's Stop block schema on
8
+ # stdout only when the answer is block; a permit prints nothing at all.
9
+ # Registration is static (hooks/hooks.json, settings.json) - this launcher
10
+ # always installs - and the decision is runtime, off by default
11
+ # (runner.stop_hook defaults false), so the owner flips continuation on or
12
+ # off by editing config with no reinstall. Every error permits: always
13
+ # exits 0, never raises out to the harness.
14
+ #
15
+ # Usage: hook-stop <plastic_home> (stdin: the hook JSON)
16
+
17
+ require "json"
18
+ require_relative "lib/stop_gate"
19
+
20
+ plastic_home = ARGV[0]
21
+ exit 0 if plastic_home.nil? || plastic_home.empty?
22
+
23
+ def read_config_value(key, home, read_config)
24
+ IO.popen({ "PLASTIC_HOME" => home }, [read_config, key], err: File::NULL, &:read).to_s.strip
25
+ rescue StandardError
26
+ ""
27
+ end
28
+
29
+ begin
30
+ raw = $stdin.tty? ? "" : $stdin.read.to_s
31
+ payload = raw.strip.empty? ? {} : JSON.parse(raw)
32
+ payload = {} unless payload.is_a?(Hash)
33
+
34
+ home = File.expand_path(plastic_home)
35
+ global_store = File.join(home, "store")
36
+ read_config = File.expand_path("read-config", __dir__)
37
+
38
+ config_stop_hook = read_config_value("runner.stop_hook", home, read_config)
39
+ roots_raw = read_config_value("project_roots", home, read_config)
40
+ project_roots =
41
+ begin
42
+ roots_raw.strip.empty? ? [] : Array(JSON.parse(roots_raw))
43
+ rescue JSON::ParserError
44
+ []
45
+ end
46
+ project_roots = project_roots.map { |root| File.expand_path(root.to_s) }
47
+
48
+ session = payload["session_id"].to_s
49
+
50
+ result = StopGate.decide(payload: payload, config_stop_hook: config_stop_hook, session: session,
51
+ global_store: global_store, project_roots: project_roots)
52
+
53
+ puts JSON.generate("decision" => "block", "reason" => result[:reason]) if result[:block]
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ exit 0
@@ -0,0 +1,61 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "lock"
5
+
6
+ # ActiveDelivery (intent 340b, G7c, n4, D10): which intent a session is
7
+ # delivering. A lock is a file inside an intent directory, and intents live
8
+ # under the global store or under any configured project's store, so
9
+ # answering "which intent" needs a walk, not an index the way node_ids.rb
10
+ # indexes ids: one Dir.glob per configured root (plus one for the global
11
+ # store) and one JSON parse per candidate lock file. No validator, no graph
12
+ # rebuild, nothing else touches disk. Shared by StopGate (the fourth arming
13
+ # condition needs to know the lock's run_mode) and Handoff (the Runner
14
+ # section needs to know which intent's runner-step.last to render).
15
+ #
16
+ # Pure and dependency-injected: every call takes explicit paths; nothing
17
+ # here reads ENV, shells out, or raises across its own boundary.
18
+ module ActiveDelivery
19
+ module_function
20
+
21
+ # resolve(global_store:, project_roots:, session:, ttl:, now:) -> intent_dir or nil.
22
+ # Nil for zero matches or more than one: a session holding two live locks
23
+ # at once is a state nothing downstream may guess through (row 4.25).
24
+ def resolve(global_store:, project_roots:, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
25
+ held = candidate_intent_dirs(global_store: global_store, project_roots: project_roots)
26
+ .uniq
27
+ .select { |dir| held_live?(dir, session: session, ttl: ttl, now: now) }
28
+ held.size == 1 ? held.first : nil
29
+ end
30
+
31
+ # Every intent directory under the global store or a configured project
32
+ # root that carries a delivery.lock file, unparsed. One Dir.glob per root
33
+ # (row 4.27): the global store's intents sit directly under store/, a
34
+ # project root's intents sit two levels down, under <root>/<slug>/store/.
35
+ def candidate_intent_dirs(global_store:, project_roots:)
36
+ dirs = []
37
+ dirs.concat(Dir.glob(File.join(global_store.to_s, "*", "delivery.lock"))) unless blank?(global_store)
38
+ Array(project_roots).each do |root|
39
+ next if blank?(root)
40
+
41
+ dirs.concat(Dir.glob(File.join(root.to_s, "*", "store", "*", "delivery.lock")))
42
+ end
43
+ dirs.map { |lock_path| File.dirname(lock_path) }
44
+ end
45
+
46
+ # Live and held by this session: a fresh (non-expired) lock whose owner or
47
+ # delegate list names this session. A torn or unparseable lock is skipped,
48
+ # never raised (row 4.26): a module a hook depends on must fail as quietly
49
+ # as the hook itself does.
50
+ def held_live?(intent_dir, session:, ttl:, now:)
51
+ return false unless Lock.fresh?(intent_dir, ttl: ttl, now: now)
52
+
53
+ Lock.holds?(intent_dir, session: session)
54
+ rescue StandardError
55
+ false
56
+ end
57
+
58
+ def blank?(value)
59
+ value.nil? || value.to_s.strip.empty?
60
+ end
61
+ end
@@ -10,9 +10,18 @@
10
10
  module AgentModels
11
11
  # Claude Code aliases only (never pinned ids, never Fable). Keys are the agent
12
12
  # file basenames without the `.md` extension.
13
+ # The three per-kind node agents (intent 340b, G7c, n2), dispatched by
14
+ # HarnessAdapter::AGENT_TYPE_BY_KIND. work and research resolve the
15
+ # executor tier, verify the advisor tier, mirroring RunnerPolicy's
16
+ # model_role split (327 D12): work and research run on plastic-executor's
17
+ # own tier, verify on the same tier plastic-advisor's imitation
18
+ # (plastic-faux-advisor) ships, never the cheap tier.
13
19
  TIER_DEFAULTS = {
14
20
  "plastic-enforcer" => "opus",
15
- "plastic-executor" => "sonnet"
21
+ "plastic-executor" => "sonnet",
22
+ "plastic-node-work" => "sonnet",
23
+ "plastic-node-verify" => "opus",
24
+ "plastic-node-research" => "sonnet"
16
25
  }.freeze
17
26
 
18
27
  # The two advisor agents (intent 185 final design): plastic-advisor (the real
@@ -0,0 +1,197 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "timeout"
6
+ require "tmpdir"
7
+ require_relative "runner_policy"
8
+
9
+ # CodexAdapter (intent 340b, G7c, n6): the argv `codex exec` needs for one
10
+ # node's kind, and the mechanics of running it once - a bounded subprocess
11
+ # whose stdin carries the packet, whose stdout and `--output-last-message`
12
+ # file are the only two places a return can come from, and whose timeout
13
+ # kills the whole process group rather than one pid.
14
+ #
15
+ # Pure where it can be: sandbox_mode, git_dir_for_worktree, add_dir_args and
16
+ # build_argv touch nothing but the filesystem read they are explicitly given
17
+ # (the worktree's own `.git` file), never a git call and never a guess at a
18
+ # repository root. #execute is the one place this module actually spawns a
19
+ # child process; scripts/node-run is the only caller.
20
+ module CodexAdapter
21
+ module_function
22
+
23
+ # Verify and research get NO write access at all (spec D9): read-only, no
24
+ # `--add-dir`. Everything else - work, and any kind this table has never
25
+ # heard of - gets the widest treatment, matching RunnerPolicy's own
26
+ # unknown-kind-falls-back-to-work rule (matrix row 6.10).
27
+ READ_ONLY_KINDS = %w[verify research].freeze
28
+
29
+ def sandbox_mode(kind)
30
+ READ_ONLY_KINDS.include?(kind.to_s) ? "read-only" : "workspace-write"
31
+ end
32
+
33
+ # The repository's OWN `.git` directory, derived from `worktree`'s `.git`
34
+ # FILE (a git worktree never carries a real `.git` directory of its own -
35
+ # spec) rather than any guess at how many path segments separate a
36
+ # worktree from its repo (matrix row 6.6). `.git` reads
37
+ # `gitdir: <repo>/.git/worktrees/<name>`; two directories up from that
38
+ # target is the repository's real `.git`. Returns nil when `worktree` is
39
+ # not a git worktree at all (no `.git` file, or `.git` is a directory,
40
+ # meaning `worktree` is a repository's own primary checkout) - callers
41
+ # treat that as "no `--add-dir` to add", never as a reason to guess.
42
+ def git_dir_for_worktree(worktree)
43
+ gitfile = File.join(worktree.to_s, ".git")
44
+ return nil unless File.file?(gitfile)
45
+
46
+ content = File.read(gitfile)
47
+ m = content.match(/\Agitdir:\s*(.+?)\s*\z/m)
48
+ return nil unless m
49
+
50
+ worktrees_entry = File.expand_path(m[1], worktree.to_s)
51
+ File.dirname(File.dirname(worktrees_entry))
52
+ end
53
+
54
+ # [] for a read-only kind (matrix row 6.4): `-C` already makes the
55
+ # worktree writable, and `--add-dir` on top of `--sandbox read-only` would
56
+ # contradict the sandbox mode itself. Exactly one `--add-dir`, the
57
+ # repository's own `.git`, for every other kind (matrix row 6.5) - nil
58
+ # when the worktree's own gitdir pointer cannot be resolved, so a caller
59
+ # handed a plain (non-worktree) directory never gets a bogus argument.
60
+ def add_dir_args(kind:, worktree:)
61
+ return [] if READ_ONLY_KINDS.include?(kind.to_s)
62
+
63
+ git_dir = git_dir_for_worktree(worktree)
64
+ git_dir ? ["--add-dir", git_dir] : []
65
+ end
66
+
67
+ # The whole `codex exec` argv for one node (matrix rows 6.1-6.9): `-C` at
68
+ # the worktree given (row 6.8), the sandbox mode and `--add-dir` this
69
+ # kind's row calls for and nothing else (row 6.7 - never the intent
70
+ # worktree, never `~/.plastic`), `--output-last-message` at the path the
71
+ # caller names, and a bare `-` so the prompt is read from stdin (row 6.9) -
72
+ # the packet itself never rides in this array.
73
+ def build_argv(kind:, worktree:, output_last_message:)
74
+ [
75
+ "codex", "exec",
76
+ "-C", worktree.to_s,
77
+ "--sandbox", sandbox_mode(kind),
78
+ *add_dir_args(kind: kind, worktree: worktree),
79
+ "--output-last-message", output_last_message.to_s,
80
+ "-",
81
+ ]
82
+ end
83
+
84
+ # matrix row 6.23: the wall-clock bound comes from the KIND's own lease
85
+ # length (RunnerPolicy.lease_minutes), never one shared constant - a long
86
+ # `work` build must not be capped as tightly as a quick `verify` pass.
87
+ def timeout_seconds(kind)
88
+ RunnerPolicy.lease_minutes(kind) * 60
89
+ end
90
+
91
+ # The return text (matrix rows 6.18/6.19): `output_last_message_path`'s
92
+ # own content when it exists and is non-blank (row 6.18 - stdout is never
93
+ # even inspected on this path), else the last YAML document found in
94
+ # `stdout` (row 6.19), else nil (no return anywhere - row 6.20 is the
95
+ # caller's problem, not this method's).
96
+ def read_return(output_last_message_path:, stdout:)
97
+ from_file = file_present_content(output_last_message_path)
98
+ return from_file unless from_file.nil?
99
+
100
+ stdout_fallback(stdout)
101
+ end
102
+
103
+ # The LAST YAML document in `text` that parses to a mapping, or nil when
104
+ # none does (row 6.20's other half). Documents are separated the ordinary
105
+ # YAML way, a line holding only `---`; a stream with no such separator at
106
+ # all is treated as one candidate document. Every candidate is tried from
107
+ # the end, so three documents (one row names explicitly) still resolve to
108
+ # the last one, and prose or a fragment that never parses to a mapping is
109
+ # skipped rather than returned as the "document".
110
+ def stdout_fallback(text)
111
+ return nil if text.to_s.strip.empty?
112
+
113
+ segments = text.to_s.split(/^---[ \t]*$/m).map(&:strip).reject(&:empty?)
114
+ segments = [text.to_s.strip] if segments.empty?
115
+
116
+ segments.reverse_each do |segment|
117
+ return segment if safe_yaml(segment).is_a?(Hash)
118
+ end
119
+ nil
120
+ end
121
+
122
+ # Runs `argv` once, feeding `stdin_data` on stdin through a scratch file
123
+ # (never a pipe - the same "never deadlock the read side" reasoning
124
+ # HookReplay.run_bounded already carries), bounded to `timeout_seconds`.
125
+ # `argv`'s own child becomes its own process group (`pgroup: true`) so a
126
+ # timeout can kill every child `codex exec` spawned along the way, not
127
+ # just the pid this call started (matrix row 6.24) - `codex exec` itself
128
+ # spawns shells for the commands it runs, and those would otherwise
129
+ # outlive a plain `Process.kill` on the parent alone, holding the
130
+ # worktree.
131
+ #
132
+ # Returns {exit_code:, timed_out:, stdout:, message:}. `message` is
133
+ # #read_return's result, computed only when the call did not time out - a
134
+ # timed-out child's partial output is never trusted (matrix row 6.22).
135
+ def execute(argv, stdin_data:, timeout_seconds:, output_last_message_path:)
136
+ Dir.mktmpdir("plastic-node-run-") do |scratch|
137
+ in_path = File.join(scratch, "stdin")
138
+ out_path = File.join(scratch, "stdout")
139
+ err_path = File.join(scratch, "stderr")
140
+ File.binwrite(in_path, stdin_data.to_s)
141
+
142
+ pid = Process.spawn(*argv.map(&:to_s), in: in_path, out: out_path, err: err_path, pgroup: true)
143
+ timed_out = false
144
+ exit_code =
145
+ begin
146
+ Timeout.timeout(timeout_seconds) do
147
+ Process.wait(pid)
148
+ $?.exitstatus
149
+ end
150
+ rescue Timeout::Error
151
+ timed_out = true
152
+ kill_group(pid)
153
+ nil
154
+ end
155
+
156
+ stdout = File.exist?(out_path) ? File.read(out_path) : ""
157
+ message = timed_out ? nil : read_return(output_last_message_path: output_last_message_path, stdout: stdout)
158
+
159
+ { exit_code: exit_code, timed_out: timed_out, stdout: stdout, message: message }
160
+ end
161
+ end
162
+
163
+ # --- internals ---------------------------------------------------------------
164
+
165
+ def file_present_content(path)
166
+ return nil if path.nil? || !File.exist?(path)
167
+
168
+ content = File.read(path)
169
+ content.strip.empty? ? nil : content
170
+ end
171
+ private_class_method :file_present_content
172
+
173
+ def safe_yaml(text)
174
+ YAML.safe_load(text, aliases: false, permitted_classes: [])
175
+ rescue Psych::Exception, ArgumentError
176
+ nil
177
+ end
178
+ private_class_method :safe_yaml
179
+
180
+ # Kills the whole process GROUP `pid` leads, not just `pid` itself (matrix
181
+ # row 6.24): a negative pid signals the group. Rescued and reaped exactly
182
+ # like HookReplay.kill_and_reap - a process that exited in the race
183
+ # between the timeout firing and this call raises Errno::ESRCH on the kill
184
+ # or Errno::ECHILD on the wait, neither of which should ever propagate.
185
+ def kill_group(pid)
186
+ Process.kill("KILL", -pid)
187
+ rescue StandardError
188
+ nil
189
+ ensure
190
+ begin
191
+ Process.wait(pid)
192
+ rescue StandardError
193
+ nil
194
+ end
195
+ end
196
+ private_class_method :kill_group
197
+ end
@@ -27,11 +27,16 @@ class Doctor
27
27
  "hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
28
28
  }.freeze
29
29
 
30
- # The Claude events hooks_registered expects in settings.json: the six-event map of
30
+ # The Claude events hooks_registered expects in settings.json: the eight-event map of
31
31
  # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
32
- # intent 316a added MessageDisplay, registered for message-display, Claude only).
32
+ # intent 316a added MessageDisplay, registered for message-display, Claude only; intent
33
+ # 355 added PreToolUse, registered for call-budget, Claude only; intent 340b added Stop,
34
+ # registered for stop, Claude only). Stop's registration is static (D5/D7) even though
35
+ # its runtime arm defaults off (D9, runner.stop_hook), so an install missing it is
36
+ # exactly as broken as one missing PreCompact, and this list keeps hooks_registered and
37
+ # hooks_match_registry agreeing on that (row 4.32).
33
38
  CLAUDE_HOOK_EVENTS = %w[SessionStart PreToolUse PreCompact PostToolUse UserPromptSubmit SessionEnd
34
- MessageDisplay].freeze
39
+ MessageDisplay Stop].freeze
35
40
 
36
41
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
37
42
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -0,0 +1,88 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # EnginePermissions: the engine deny rule (intent 340b, G7c, n3). A permissions.deny
5
+ # block merged into settings.json at install so the engine directories are not
6
+ # editable by a dispatched agent (C25's self-preservation half).
7
+ #
8
+ # Claude Code accepts a Write(...) path rule and never consults it: Edit(...) is the
9
+ # rule that covers Write, MultiEdit and NotebookEdit. Every entry below names Edit,
10
+ # never Write, and every entry ends in /** because a bare directory path matches the
11
+ # directory itself, not the files under it.
12
+ #
13
+ # This is a second ownership mechanism, deliberately not a reuse of HookRegistry's
14
+ # merge, which knows its own entries by a plastic- launcher basename inside a command
15
+ # string. A deny entry is a bare string with no marker in it, so ownership here is
16
+ # exact-string membership in ENTRIES. The consequence is deliberate: an entry the
17
+ # owner has edited no longer matches the frozen string, so it is the owner's from
18
+ # that point on - the merge appends Plastic's own alongside it, and removal leaves
19
+ # the edited one in place untouched.
20
+ #
21
+ # Pure functions only: no file I/O here. InstallerCore#merge_engine_permissions and
22
+ # #remove_engine_permissions own the read-modify-write against settings.json,
23
+ # mirroring the split HookRegistry keeps from merge_claude_hooks.
24
+ module EnginePermissions
25
+ module_function
26
+
27
+ ENTRIES = [
28
+ "Edit(~/.plastic/scripts/**)",
29
+ "Edit(~/.plastic/skills/**)",
30
+ "Edit(~/.plastic/hooks/**)",
31
+ "Edit(~/.plastic/templates/**)",
32
+ ].freeze
33
+
34
+ # Returns a new settings hash with ENTRIES merged into permissions.deny, appended
35
+ # (never duplicated) alongside whatever is already there. Survives a permissions
36
+ # or deny value that is not the shape expected (row 3.12): a non-Hash permissions
37
+ # value or a non-Array deny value is replaced rather than raised on. Never
38
+ # mutates the argument.
39
+ def merge_into(settings)
40
+ settings = settings.is_a?(Hash) ? settings.dup : {}
41
+
42
+ permissions = settings["permissions"]
43
+ permissions = permissions.is_a?(Hash) ? permissions.dup : {}
44
+
45
+ deny = permissions["deny"]
46
+ deny = deny.is_a?(Array) ? deny.dup : []
47
+
48
+ ENTRIES.each { |entry| deny << entry unless deny.include?(entry) }
49
+
50
+ permissions["deny"] = deny
51
+ settings["permissions"] = permissions
52
+ settings
53
+ end
54
+
55
+ # Returns a new settings hash with exactly the ENTRIES strings removed from
56
+ # permissions.deny, leaving every other entry (the owner's own deny rules, and
57
+ # any Plastic entry the owner has since edited) untouched. Prunes the deny array
58
+ # once it is empty, and the whole permissions block once nothing else remains
59
+ # under it (row 3.10). A settings/permissions/deny shape that is not what is
60
+ # expected is left alone rather than raised on (row 3.12's uninstall side).
61
+ def remove_from(settings)
62
+ return settings unless settings.is_a?(Hash)
63
+
64
+ permissions = settings["permissions"]
65
+ return settings unless permissions.is_a?(Hash)
66
+
67
+ deny = permissions["deny"]
68
+ return settings unless deny.is_a?(Array)
69
+
70
+ settings = settings.dup
71
+ permissions = permissions.dup
72
+ deny = deny.reject { |entry| ENTRIES.include?(entry) }
73
+
74
+ if deny.empty?
75
+ permissions.delete("deny")
76
+ else
77
+ permissions["deny"] = deny
78
+ end
79
+
80
+ if permissions.empty?
81
+ settings.delete("permissions")
82
+ else
83
+ settings["permissions"] = permissions
84
+ end
85
+
86
+ settings
87
+ end
88
+ end
@@ -4,11 +4,11 @@
4
4
  # GraphEdges (intent 334, n1): the shared `needs` syntax and cycle-path check
5
5
  # (327 D2r-D4r, D12r). Parses a "## Graph" section's edge lines into a node
6
6
  # set and an edge map, the one parser an intent's graph.md and a roadmap's
7
- # own ## Graph section both use unchanged (C1, fold A5): the same list-item
7
+ # own ## Graph section both use unchanged (C1, review A5): the same list-item
8
8
  # grammar, the same root keyword, the same cycle walk. Deliberately loose
9
9
  # about what an id looks like - a roadmap id is numeric ("334", "340a"), a
10
10
  # node id carries a kind prefix ("n1") - the kind-prefix rule belongs to
11
- # NodeFile and WorkGraphValidator, never here (D12r, fold A6).
11
+ # NodeFile and WorkGraphValidator, never here (D12r, review A6).
12
12
  #
13
13
  # Grammar (D2r/D3r): a list item shaped "- <id> needs <target> [<target>
14
14
  # ...]", ending at end of line. The single literal target "nothing" declares
@@ -18,7 +18,7 @@
18
18
  # all, "nothing" alongside other targets, or a target token carrying
19
19
  # sentence punctuation (a period, a colon, ...) rather than the loose
20
20
  # id-token charset, is an error naming the line, never a silently
21
- # mis-parsed edge or an invented node (fold A7).
21
+ # mis-parsed edge or an invented node (review A7).
22
22
  #
23
23
  # Pure and side-effect-free; never raises across the boundary, matching the
24
24
  # Result-hash convention every library in scripts/lib/ follows.
@@ -30,7 +30,7 @@ module GraphEdges
30
30
  ROOT_TARGET = "nothing"
31
31
 
32
32
  # {nodes:, edges:, errors:} - nodes is the declared ids together with every
33
- # id any edge targets (fold A5), in first-seen order; edges maps a declared
33
+ # id any edge targets (review A5), in first-seen order; edges maps a declared
34
34
  # id to its target ids (empty for a root); errors names each malformed
35
35
  # edge-shaped line. Prose lines contribute nothing and raise nothing.
36
36
  def parse(section_text)
@@ -7,9 +7,9 @@ require_relative "atomic_write"
7
7
  # GraphFile (intent 334, n2): the four graph.md sections (## Goal,
8
8
  # ## Decisions, ## Graph, ## Status), the verify:-none directive, and the two
9
9
  # writers - write_status and append_decision. Both writers refuse a cyclic
10
- # graph and both go through AtomicWrite (fold D19r). Fence-aware everywhere a
10
+ # graph and both go through AtomicWrite (review D19r). Fence-aware everywhere a
11
11
  # heading is located, so a fenced example carrying a fake "## " line never
12
- # splits or ends a real section (fold A8's sibling concern, applied to
12
+ # splits or ends a real section (review A8's sibling concern, applied to
13
13
  # section boundaries rather than edge lines).
14
14
  module GraphFile
15
15
  module_function
@@ -19,8 +19,8 @@ module GraphFile
19
19
 
20
20
  # {ok:, goal:, decisions:, graph:, status:, verify:, errors:}. `graph` is
21
21
  # GraphEdges.parse's own Result hash, over the Graph section text with any
22
- # verify:-none directive line stripped first (fold A8). A missing ## Graph
23
- # section, or one that declares no nodes, is an error naming which (fold
22
+ # verify:-none directive line stripped first (review A8). A missing ## Graph
23
+ # section, or one that declares no nodes, is an error naming which (review
24
24
  # A10).
25
25
  def parse(path)
26
26
  return failure(["graph file not found: #{path}"]) unless File.exist?(path)