@zalom/plastic 2.0.0-alpha.21 → 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/PLASTIC.md +20 -18
- 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 +159 -10
- 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 +333 -321
- package/scripts/hook-stop +57 -0
- package/scripts/insight-append +18 -4
- 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 +112 -6
- 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/skill-lint +115 -6
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +54 -56
- package/skills/auto/references/agent-architecture.md +10 -8
- package/skills/auto/references/human-report-contract.md +1 -1
- package/skills/conventions/references/completion-and-done.md +7 -7
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/maintenance-and-revisions.md +1 -1
- package/skills/doctor/SKILL.md +3 -3
- package/skills/doctor/report.md +1 -1
- package/skills/intent-continuing/references/boarding-matrix.md +2 -2
- package/skills/intent-creating/SKILL.md +58 -133
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-ending/SKILL.md +48 -56
- package/skills/intent-ending/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +43 -136
- package/skills/intent-speccing/SKILL.md +3 -0
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/SKILL.md +2 -1
- package/skills/tutorial/references/track-1-guided.md +21 -40
- package/skills/tutorial/references/track-2-auto.md +3 -3
- package/templates/agents.md +2 -2
|
@@ -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
|
package/scripts/insight-append
CHANGED
|
@@ -11,8 +11,16 @@
|
|
|
11
11
|
# `now:` seam is the test seam; the CLI uses the default Time.now, which is fine
|
|
12
12
|
# because determinism is covered at the library level (test/insights_test.rb).
|
|
13
13
|
#
|
|
14
|
+
# --rule (intent 341, G8, C37): tags the entry as a rule, not just an
|
|
15
|
+
# observation, by prepending the literal "rule: " onto the text before it
|
|
16
|
+
# ever reaches Insights.append_insight; the library itself stays untouched,
|
|
17
|
+
# since a tag is a text-level convention, not a new field on the ledger.
|
|
18
|
+
# Doctor's unpromoted_rules check (scripts/doctor.rb) later lists any tagged
|
|
19
|
+
# entry whose exact text no skills/conventions/references/*.md chapter
|
|
20
|
+
# carries yet.
|
|
21
|
+
#
|
|
14
22
|
# Usage:
|
|
15
|
-
# insight-append <intent_dir> <text> --stage S --author A
|
|
23
|
+
# insight-append <intent_dir> <text> --stage S --author A [--rule]
|
|
16
24
|
#
|
|
17
25
|
# Exit codes: 0 (entry appended), 2 (usage).
|
|
18
26
|
|
|
@@ -21,6 +29,7 @@ require_relative "lib/insights"
|
|
|
21
29
|
def parse_args(argv)
|
|
22
30
|
stage = nil
|
|
23
31
|
author = nil
|
|
32
|
+
rule = false
|
|
24
33
|
positional = []
|
|
25
34
|
i = 0
|
|
26
35
|
while i < argv.length
|
|
@@ -31,21 +40,26 @@ def parse_args(argv)
|
|
|
31
40
|
when "--author"
|
|
32
41
|
author = argv[i + 1]
|
|
33
42
|
i += 2
|
|
43
|
+
when "--rule"
|
|
44
|
+
rule = true
|
|
45
|
+
i += 1
|
|
34
46
|
else
|
|
35
47
|
positional << argv[i]
|
|
36
48
|
i += 1
|
|
37
49
|
end
|
|
38
50
|
end
|
|
39
|
-
[positional[0], positional[1], stage, author]
|
|
51
|
+
[positional[0], positional[1], stage, author, rule]
|
|
40
52
|
end
|
|
41
53
|
|
|
42
|
-
intent_dir, text, stage, author = parse_args(ARGV)
|
|
54
|
+
intent_dir, text, stage, author, rule = parse_args(ARGV)
|
|
43
55
|
|
|
44
56
|
if [intent_dir, text, stage, author].any? { |v| v.nil? || v.to_s.empty? }
|
|
45
|
-
warn "usage: insight-append <intent_dir> <text> --stage S --author A"
|
|
57
|
+
warn "usage: insight-append <intent_dir> <text> --stage S --author A [--rule]"
|
|
46
58
|
exit 2
|
|
47
59
|
end
|
|
48
60
|
|
|
61
|
+
text = "rule: #{text}" if rule
|
|
62
|
+
|
|
49
63
|
entry = Insights.append_insight(File.expand_path(intent_dir), text,
|
|
50
64
|
stage: stage, author: author)
|
|
51
65
|
puts "appended: #{entry}"
|
|
@@ -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)
|