@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
package/scripts/hook-savepoint
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
23
|
-
# section, or one that declares no nodes, is an error naming which (
|
|
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)
|