@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24
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 +6 -5
- 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/dashboard.rb +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +22 -19
- package/scripts/exec-worktree +5 -5
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +16 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +26 -3
- package/scripts/hook-session-start +49 -10
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- 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 +409 -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 +40 -13
- 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_entry.rb +53 -0
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +228 -9
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +8 -9
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +11 -2
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +2 -2
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.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/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +36 -10
- package/scripts/lib/runner_dispatch.rb +66 -51
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +9 -10
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +22 -16
- package/scripts/lib/session_ledger.rb +44 -4
- 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/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/new-intent +1 -1
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +224 -0
- package/scripts/plastic-lock +33 -32
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +8 -8
- package/skills/auto/references/end-tail.md +9 -11
- package/skills/conventions/references/completion-and-done.md +9 -10
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +4 -4
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +7 -1
- package/skills/releasing/SKILL.md +2 -2
- package/skills/releasing/references/promotion-and-tagging.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "lock"
|
|
6
|
+
|
|
7
|
+
# ActiveDelivery (intent 340b, G7c, n4, D10): which intent a session is
|
|
8
|
+
# delivering. A lock is a file inside an intent directory, and intents live
|
|
9
|
+
# under the global store or under any configured project's store, so
|
|
10
|
+
# answering "which intent" needs a walk, not an index the way node_ids.rb
|
|
11
|
+
# indexes ids: one Dir.glob per configured root (plus one for the global
|
|
12
|
+
# store) and one JSON parse per candidate lock file. No validator, no graph
|
|
13
|
+
# rebuild, nothing else touches disk. Shared by StopGate (the fourth arming
|
|
14
|
+
# condition needs to know the lock's run_mode) and Handoff (the Runner
|
|
15
|
+
# section needs to know which intent's runner-step.last to render).
|
|
16
|
+
#
|
|
17
|
+
# Pure and dependency-injected: every call takes explicit paths; nothing
|
|
18
|
+
# here reads ENV, shells out, or raises across its own boundary.
|
|
19
|
+
module ActiveDelivery
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# resolve(global_store:, project_roots:, session:, ttl:, now:) -> intent_dir or nil.
|
|
23
|
+
# Nil for zero matches or more than one: a session holding two live locks
|
|
24
|
+
# at once is a state nothing downstream may guess through (row 4.25).
|
|
25
|
+
def resolve(global_store:, project_roots:, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
26
|
+
held = candidate_intent_dirs(global_store: global_store, project_roots: project_roots)
|
|
27
|
+
.uniq
|
|
28
|
+
.select { |dir| held_live?(dir, session: session, ttl: ttl, now: now) }
|
|
29
|
+
held.size == 1 ? held.first : nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Every intent directory under the global store or a configured project
|
|
33
|
+
# root that carries a delivery.lock file, unparsed. One Dir.glob per root
|
|
34
|
+
# (row 4.27): the global store's intents sit directly under store/, a
|
|
35
|
+
# project root's intents sit two levels down, under <root>/<slug>/store/.
|
|
36
|
+
def candidate_intent_dirs(global_store:, project_roots:)
|
|
37
|
+
dirs = []
|
|
38
|
+
dirs.concat(Dir.glob(File.join(global_store.to_s, "*", "delivery.lock"))) unless blank?(global_store)
|
|
39
|
+
Array(project_roots).each do |root|
|
|
40
|
+
next if blank?(root)
|
|
41
|
+
|
|
42
|
+
dirs.concat(Dir.glob(File.join(root.to_s, "*", "store", "*", "delivery.lock")))
|
|
43
|
+
end
|
|
44
|
+
dirs.map { |lock_path| File.dirname(lock_path) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Live and held by this session: a fresh (non-expired) lock whose owner or
|
|
48
|
+
# delegate list names this session. A torn or unparseable lock is skipped,
|
|
49
|
+
# never raised (row 4.26): a module a hook depends on must fail as quietly
|
|
50
|
+
# as the hook itself does.
|
|
51
|
+
def held_live?(intent_dir, session:, ttl:, now:)
|
|
52
|
+
return false unless Lock.fresh?(intent_dir, ttl: ttl, now: now)
|
|
53
|
+
|
|
54
|
+
Lock.holds?(intent_dir, session: session)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def blank?(value)
|
|
60
|
+
value.nil? || value.to_s.strip.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
DEFAULT_PROJECT_ROOTS = ["~/.plastic/projects"].freeze
|
|
64
|
+
|
|
65
|
+
# The configured project roots (344 n2, D6): the `project_roots` list in
|
|
66
|
+
# `<plastic_home>/config.yml`, each expanded, or the default
|
|
67
|
+
# `~/.plastic/projects` when the key is absent or the file cannot be read
|
|
68
|
+
# or parsed. Never raises: a broken config.yml must never crash a hook on
|
|
69
|
+
# every prompt.
|
|
70
|
+
def project_roots(plastic_home)
|
|
71
|
+
path = File.join(plastic_home.to_s, "config.yml")
|
|
72
|
+
data = File.exist?(path) ? YAML.safe_load(File.read(path)) : nil
|
|
73
|
+
roots = data.is_a?(Hash) ? data["project_roots"] : nil
|
|
74
|
+
roots = DEFAULT_PROJECT_ROOTS unless roots.is_a?(Array) && !roots.empty?
|
|
75
|
+
roots.map { |root| File.expand_path(root.to_s) }
|
|
76
|
+
rescue StandardError
|
|
77
|
+
DEFAULT_PROJECT_ROOTS.map { |root| File.expand_path(root) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# resolve_by_short_session(...) -> intent_dir or nil (344 n2). The tmp dir
|
|
81
|
+
# name is the short session id, while lock owners and delegates carry full
|
|
82
|
+
# ids, so this matches a lock whose owner or a delegate session STARTS WITH
|
|
83
|
+
# the short id. Nil for zero or more than one match, mirroring resolve's
|
|
84
|
+
# own uniqueness rule: an ambiguous short id names nothing downstream may
|
|
85
|
+
# guess through.
|
|
86
|
+
def resolve_by_short_session(global_store:, project_roots:, short_session:,
|
|
87
|
+
ttl: Lock::TTL_SECONDS, now: Time.now)
|
|
88
|
+
held = candidate_intent_dirs(global_store: global_store, project_roots: project_roots)
|
|
89
|
+
.uniq
|
|
90
|
+
.select { |dir| held_live_by_short?(dir, short_session: short_session, ttl: ttl, now: now) }
|
|
91
|
+
held.size == 1 ? held.first : nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def held_live_by_short?(intent_dir, short_session:, ttl:, now:)
|
|
95
|
+
return false unless Lock.fresh?(intent_dir, ttl: ttl, now: now)
|
|
96
|
+
|
|
97
|
+
data = Lock.read(intent_dir)
|
|
98
|
+
return false unless data.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
sessions = [data["owner_session"]] + Array(data["delegates"])
|
|
101
|
+
sessions.compact.map(&:to_s).any? { |session| session.start_with?(short_session.to_s) }
|
|
102
|
+
rescue StandardError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
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
|
package/scripts/lib/arm.rb
CHANGED
|
@@ -10,13 +10,14 @@ require_relative "savepoint"
|
|
|
10
10
|
|
|
11
11
|
# Arm - how an auto team takes an intent and gives it back (intent 307).
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
# directory
|
|
15
|
-
# the code lands
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
13
|
+
# Two durable facts make a delivery: the delivery lock in the intent
|
|
14
|
+
# directory says who delivers, and the code worktree under the project repo
|
|
15
|
+
# says where the code lands. A conversation session's tmp directory under
|
|
16
|
+
# the global store's `.tmp/` says only that a session started; it names no
|
|
17
|
+
# intent. This module carries the
|
|
18
|
+
# arm, disarm, and repair operations (intent 307); the pure INDEX/project-config
|
|
19
|
+
# helpers that once sat alongside them on a shared-helpers module now live on
|
|
20
|
+
# IndexEntry and ProjectConfig (intent 344).
|
|
20
21
|
#
|
|
21
22
|
# Pure and dependency-injected: every path and clock is an argument, every git
|
|
22
23
|
# call goes through an injected runner, and the only environment read is the
|
|
@@ -32,7 +33,7 @@ module Arm
|
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# Deterministic, session-less key derived from the store and the intent id,
|
|
35
|
-
# for headless runs that carry no session id at all
|
|
36
|
+
# for headless runs that carry no session id at all.
|
|
36
37
|
def derive_key(store, intent_id)
|
|
37
38
|
"auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
|
|
38
39
|
end
|
|
@@ -67,7 +68,7 @@ module Arm
|
|
|
67
68
|
|
|
68
69
|
# The minimal hash Worktree.provision, release, and finish consume: the
|
|
69
70
|
# intent block plus, when asked, the derived worktree block.
|
|
70
|
-
def
|
|
71
|
+
def delivery(intent_dir:, home: Dir.home, with_worktree: true)
|
|
71
72
|
dir = File.expand_path(intent_dir)
|
|
72
73
|
data = {
|
|
73
74
|
"intent" => { "id" => intent_id_for(dir), "dir" => File.basename(dir), "store" => store_for(dir) },
|
|
@@ -94,48 +95,26 @@ module Arm
|
|
|
94
95
|
}
|
|
95
96
|
end
|
|
96
97
|
|
|
97
|
-
# Owner rule 2026-08-31:
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
|
|
98
|
+
# Owner rule 2026-08-31: has this session already started a conversation?
|
|
99
|
+
# A conversation session's tmp directory is made at boot under the global
|
|
100
|
+
# store's `.tmp/`; a dispatched or headless session (a derived key) never
|
|
101
|
+
# gets one. Reads only; rescues to false (fail open: a broken store must
|
|
102
|
+
# never block a legitimate delivery).
|
|
103
|
+
def started_session?(session, home:)
|
|
104
|
+
store = global_store(home)
|
|
105
|
+
Dir.exist?(SessionLedger.session_tmp_dir(store, SessionLedger.short_session_id(nil, session)))
|
|
105
106
|
rescue StandardError
|
|
106
107
|
false
|
|
107
108
|
end
|
|
108
109
|
|
|
109
|
-
# --- the pointer -------------------------------------------------------------
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def pointer_path(session, home:)
|
|
113
|
-
store = global_store(home)
|
|
114
|
-
SessionLedger.pointer_path(store, SessionLedger.short_session_id(nil, session))
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def read_pointer(session, home:)
|
|
118
|
-
path = pointer_path(session, home: home)
|
|
119
|
-
File.exist?(path) ? File.read(path).strip : nil
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def write_pointer(session, value, home:)
|
|
123
|
-
store = global_store(home)
|
|
124
|
-
SessionLedger.ensure_tmp_root(store)
|
|
125
|
-
path = pointer_path(session, home: home)
|
|
126
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
127
|
-
File.write(path, "#{value}\n")
|
|
128
|
-
path
|
|
129
|
-
end
|
|
130
|
-
|
|
131
110
|
# --- arm ---------------------------------------------------------------------
|
|
132
111
|
|
|
133
112
|
# Take an intent for `session`: acquire delivery.lock (stamped with the run
|
|
134
|
-
# mode and the provenance the caller knows)
|
|
135
|
-
# (fail-open for a global-only or non-git intent)
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
113
|
+
# mode and the provenance the caller knows) and provision the code worktree
|
|
114
|
+
# (fail-open for a global-only or non-git intent). Returns
|
|
115
|
+
# `{status:, lock:, worktree:, session:}`. A held, stale, excluded, or
|
|
116
|
+
# corrupt lock returns that status with the lock data read and touches
|
|
117
|
+
# nothing.
|
|
139
118
|
def arm(intent_dir:, session:, mode: "auto", home: Dir.home, harness: nil,
|
|
140
119
|
agent: nil, model: nil, thread: nil, now: Time.now, runner: Worktree::ShellRunner.new,
|
|
141
120
|
host: Socket.gethostname, allow_inline: false)
|
|
@@ -145,52 +124,39 @@ module Arm
|
|
|
145
124
|
h = home_for(dir, home: home)
|
|
146
125
|
|
|
147
126
|
# Owner rule 2026-08-31: the main session never delivers an intent inline.
|
|
148
|
-
# A session
|
|
149
|
-
#
|
|
150
|
-
# inline delivery and is refused BEFORE any lock is taken.
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
if !allow_inline &&
|
|
155
|
-
|
|
156
|
-
return { status: :inline_refused, lock: nil, worktree: nil, session: key, pointer: nil }
|
|
127
|
+
# A conversation session (its tmp directory already exists under the
|
|
128
|
+
# global store) arming an intent it does not already hold the lock for is
|
|
129
|
+
# inline delivery and is refused BEFORE any lock is taken. Re-arming the
|
|
130
|
+
# intent it already holds stays idempotent. A dispatched or headless
|
|
131
|
+
# session has no tmp directory and arms freely. --allow-inline is the
|
|
132
|
+
# explicit owner override.
|
|
133
|
+
if !allow_inline && started_session?(key, home: h) && !Lock.holds?(dir, session: key)
|
|
134
|
+
return { status: :inline_refused, lock: nil, worktree: nil, session: key }
|
|
157
135
|
end
|
|
158
136
|
|
|
159
137
|
status, lock = Lock.acquire(dir, session: key, host: host, now: now,
|
|
160
138
|
harness: harness, agent: agent, model: model,
|
|
161
139
|
thread: thread, run_mode: mode.to_s)
|
|
162
140
|
unless %i[acquired owned].include?(status)
|
|
163
|
-
return { status: status, lock: lock, worktree: nil, session: key
|
|
141
|
+
return { status: status, lock: lock, worktree: nil, session: key }
|
|
164
142
|
end
|
|
165
143
|
|
|
166
|
-
data =
|
|
144
|
+
data = delivery(intent_dir: dir, home: h, with_worktree: false)
|
|
167
145
|
begin
|
|
168
146
|
Worktree.provision(data, home: h, runner: runner)
|
|
169
147
|
rescue StandardError => e
|
|
170
148
|
warn "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
|
|
171
149
|
end
|
|
172
150
|
|
|
173
|
-
# The pointer is what the capture and record hooks read; they key on the
|
|
174
|
-
# harness's real session id, so a derived key gets no pointer (nothing would
|
|
175
|
-
# ever read it). A pointer failure warns and never undoes an acquired lock.
|
|
176
|
-
pointer = nil
|
|
177
|
-
unless key == derive_key(store_for(dir), intent_id_for(dir))
|
|
178
|
-
begin
|
|
179
|
-
pointer = write_pointer(key, intent_id_for(dir), home: h)
|
|
180
|
-
rescue StandardError => e
|
|
181
|
-
warn "plastic: session pointer not written (#{e.message}); the lock is held regardless"
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
151
|
{ status: status, lock: lock, worktree: worktree_block(intent_dir: dir, home: h),
|
|
185
|
-
session: key
|
|
152
|
+
session: key }
|
|
186
153
|
end
|
|
187
154
|
|
|
188
155
|
# --- disarm ------------------------------------------------------------------
|
|
189
156
|
|
|
190
|
-
# Give the intent back: remove the worktree (when `remove`)
|
|
191
|
-
# lock as its recorded owner (falling back to `session`)
|
|
192
|
-
#
|
|
193
|
-
# the lock release status (:released, :none, :not_owner, or :raised).
|
|
157
|
+
# Give the intent back: remove the worktree (when `remove`) and release the
|
|
158
|
+
# lock as its recorded owner (falling back to `session`). Returns the lock
|
|
159
|
+
# release status (:released, :none, :not_owner, or :raised).
|
|
194
160
|
def disarm(intent_dir:, session:, home: Dir.home, runner: Worktree::ShellRunner.new,
|
|
195
161
|
remove: true, now: Time.now)
|
|
196
162
|
dir = File.expand_path(intent_dir)
|
|
@@ -198,39 +164,24 @@ module Arm
|
|
|
198
164
|
key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
|
|
199
165
|
|
|
200
166
|
begin
|
|
201
|
-
Worktree.release(
|
|
167
|
+
Worktree.release(delivery(intent_dir: dir, home: h), home: h, runner: runner, remove: remove)
|
|
202
168
|
rescue StandardError => e
|
|
203
169
|
warn "plastic: worktree release raised, continuing: #{e.message}"
|
|
204
170
|
end
|
|
205
171
|
|
|
206
172
|
lock = Lock.read(dir)
|
|
207
173
|
owner = lock && !blank?(lock["owner_session"]) ? lock["owner_session"] : key
|
|
208
|
-
|
|
174
|
+
begin
|
|
209
175
|
Lock.release(dir, session: owner)
|
|
210
176
|
rescue StandardError => e
|
|
211
177
|
warn "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
|
|
212
178
|
:raised
|
|
213
179
|
end
|
|
214
|
-
|
|
215
|
-
begin
|
|
216
|
-
reset_pointer(key, intent_id_for(dir), home: h, now: now)
|
|
217
|
-
rescue StandardError => e
|
|
218
|
-
warn "plastic: session pointer not reset (#{e.message})"
|
|
219
|
-
end
|
|
220
|
-
release_status
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# Point the session back at the day ledger, but only when it names `intent_id`.
|
|
224
|
-
def reset_pointer(session, intent_id, home:, now: Time.now)
|
|
225
|
-
current = read_pointer(session, home: home)
|
|
226
|
-
return false unless current == intent_id.to_s
|
|
227
|
-
write_pointer(session, SessionLedger.day_id(now), home: home)
|
|
228
|
-
true
|
|
229
180
|
end
|
|
230
181
|
|
|
231
182
|
# --- repair ------------------------------------------------------------------
|
|
232
183
|
|
|
233
|
-
# One idempotent repair (the lock half of the
|
|
184
|
+
# One idempotent repair (the lock half of the repair path):
|
|
234
185
|
# remove a corrupt lock, back off from a fresh foreign lock (`held`), report
|
|
235
186
|
# a stale foreign lock (`stale`) for the explicit reclaim verb, keep and
|
|
236
187
|
# enrich an own lock, heartbeat a delegated one, acquire when none, and
|
|
@@ -284,7 +235,7 @@ module Arm
|
|
|
284
235
|
end
|
|
285
236
|
|
|
286
237
|
begin
|
|
287
|
-
Worktree.provision(
|
|
238
|
+
Worktree.provision(delivery(intent_dir: dir, home: h, with_worktree: false), home: h, runner: runner)
|
|
288
239
|
rescue StandardError => e
|
|
289
240
|
warn "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
|
|
290
241
|
end
|
|
@@ -293,16 +244,4 @@ module Arm
|
|
|
293
244
|
|
|
294
245
|
{ "status" => "repaired", "actions" => actions, "lock" => lock_data, "session" => key }
|
|
295
246
|
end
|
|
296
|
-
|
|
297
|
-
# The intent directory this session's pointer names, searched in the given
|
|
298
|
-
# stores in order; nil when the pointer is absent, empty, or a day id.
|
|
299
|
-
def intent_dir_from_pointer(session, home:, stores:)
|
|
300
|
-
current = read_pointer(session, home: home)
|
|
301
|
-
return nil if blank?(current) || SessionLedger.valid_day_id?(current)
|
|
302
|
-
stores.each do |store|
|
|
303
|
-
match = Dir.glob(File.join(store, "#{current}--*")).find { |p| File.directory?(p) }
|
|
304
|
-
return match if match
|
|
305
|
-
end
|
|
306
|
-
nil
|
|
307
|
-
end
|
|
308
247
|
end
|
|
@@ -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 node input, 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 node input 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
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# DataBoundary (intent 338, G5, n1): the trust boundary a node input is
|
|
7
7
|
# built over. A data block carries text other agents and the owner wrote,
|
|
8
8
|
# and is opened by `<<<PLASTIC-DATA:<token> label="..." source="...">>>` and
|
|
9
9
|
# closed by `<<<END-PLASTIC-DATA:<token>>>`. The token is content-derived
|
|
10
10
|
# (spec D4), so the same payloads always produce the same token and the
|
|
11
|
-
#
|
|
12
|
-
# payload independently of the token, so a payload carrying this
|
|
11
|
+
# node input stays deterministic; the escaping rule (spec D5) runs on every
|
|
12
|
+
# payload independently of the token, so a payload carrying this node input's
|
|
13
13
|
# own closing marker still cannot close its block. Attribute values
|
|
14
14
|
# (`label`, `source`) are sanitized separately, by a whitelist (spec D5a),
|
|
15
15
|
# because they are not payload text and the payload escaping rule does not
|
|
@@ -18,7 +18,7 @@ require "digest"
|
|
|
18
18
|
# Pure and side-effect-free: no I/O, no clock, nothing raised across the
|
|
19
19
|
# boundary. `estimate_tokens` is the one arithmetic every budget in this
|
|
20
20
|
# delivery is spent in (spec D6): bytes over four, rounded.
|
|
21
|
-
module
|
|
21
|
+
module DataBoundary
|
|
22
22
|
module_function
|
|
23
23
|
|
|
24
24
|
MARKER_OPEN = "<<<PLASTIC-DATA:"
|
|
@@ -34,16 +34,16 @@ module PacketWrapper
|
|
|
34
34
|
CLOSE_LINE_RE = /\A#{Regexp.escape(MARKER_CLOSE)}([0-9a-f]+)>>>\z/.freeze
|
|
35
35
|
|
|
36
36
|
# The first twelve hex characters of the SHA-256 over the payloads joined
|
|
37
|
-
# by a newline (spec D4): fixed per
|
|
37
|
+
# by a newline (spec D4): fixed per node input, unguessable from any single
|
|
38
38
|
# record file, deterministic across two builds of the same payloads.
|
|
39
39
|
def boundary_token(payloads)
|
|
40
40
|
Digest::SHA256.hexdigest(Array(payloads).map(&:to_s).join("\n"))[0, 12]
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# One-way marker escaping (spec D5), plus a UTF-8 scrub (matrix 1.10) so
|
|
44
|
-
# one invalid byte anywhere in a record never raises the whole
|
|
44
|
+
# one invalid byte anywhere in a record never raises the whole node input
|
|
45
45
|
# build. Runs regardless of what token trails the marker text, because a
|
|
46
|
-
# payload copied from an earlier
|
|
46
|
+
# payload copied from an earlier node input may carry ANY token, not only this
|
|
47
47
|
# one (matrix 1.2's concern applied to escaping rather than to the token
|
|
48
48
|
# itself).
|
|
49
49
|
def escape(payload)
|