@zalom/plastic 2.0.0-alpha.23 → 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-node-research.md +4 -4
- package/agents/plastic-node-verify.md +4 -4
- package/agents/plastic-node-work.md +4 -4
- package/package.json +1 -1
- package/scripts/dashboard.rb +1 -1
- package/scripts/end-intent +19 -16
- package/scripts/exec-worktree +5 -5
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +15 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +2 -1
- package/scripts/hook-session-start +9 -10
- package/scripts/lib/active_delivery.rb +44 -0
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +2 -2
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/graph_measure_budget.rb +29 -28
- package/scripts/lib/handoff.rb +4 -8
- package/scripts/lib/harness_adapter.rb +6 -6
- package/scripts/lib/index_entry.rb +53 -0
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +120 -7
- package/scripts/lib/lock.rb +8 -9
- 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 +4 -2
- package/scripts/lib/outcome_report.rb +1 -1
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +5 -5
- package/scripts/lib/runner_dispatch.rb +44 -44
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +1 -1
- package/scripts/lib/savepoint.rb +6 -7
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +20 -14
- package/scripts/lib/session_ledger.rb +44 -4
- package/scripts/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +19 -19
- package/scripts/plastic-lock +33 -32
- package/scripts/runner +1 -1
- package/skills/auto/SKILL.md +7 -7
- 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 +1 -1
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +1 -1
- package/skills/intent-executing/SKILL.md +1 -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/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
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
|
|
@@ -8,7 +8,7 @@ require_relative "runner_policy"
|
|
|
8
8
|
|
|
9
9
|
# CodexAdapter (intent 340b, G7c, n6): the argv `codex exec` needs for one
|
|
10
10
|
# node's kind, and the mechanics of running it once - a bounded subprocess
|
|
11
|
-
# whose stdin carries the
|
|
11
|
+
# whose stdin carries the node input, whose stdout and `--output-last-message`
|
|
12
12
|
# file are the only two places a return can come from, and whose timeout
|
|
13
13
|
# kills the whole process group rather than one pid.
|
|
14
14
|
#
|
|
@@ -69,7 +69,7 @@ module CodexAdapter
|
|
|
69
69
|
# kind's row calls for and nothing else (row 6.7 - never the intent
|
|
70
70
|
# worktree, never `~/.plastic`), `--output-last-message` at the path the
|
|
71
71
|
# caller names, and a bare `-` so the prompt is read from stdin (row 6.9) -
|
|
72
|
-
# the
|
|
72
|
+
# the node input itself never rides in this array.
|
|
73
73
|
def build_argv(kind:, worktree:, output_last_message:)
|
|
74
74
|
[
|
|
75
75
|
"codex", "exec",
|
|
@@ -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)
|
|
@@ -12,6 +12,7 @@ require "time"
|
|
|
12
12
|
require_relative "session_ledger"
|
|
13
13
|
require_relative "handoff"
|
|
14
14
|
require_relative "lock"
|
|
15
|
+
require_relative "active_delivery"
|
|
15
16
|
require_relative "report_screen"
|
|
16
17
|
|
|
17
18
|
module DaySummary
|
|
@@ -43,7 +44,7 @@ module DaySummary
|
|
|
43
44
|
open: open_items(store, day),
|
|
44
45
|
done: last_done(store, day),
|
|
45
46
|
live: live_intents(home, now: now),
|
|
46
|
-
others: active_sessions(store, session, now: now, ttl: heartbeat_ttl),
|
|
47
|
+
others: active_sessions(store, session, now: now, ttl: heartbeat_ttl, home: home),
|
|
47
48
|
}
|
|
48
49
|
hidden = Hash.new(0)
|
|
49
50
|
cap!(lists, hidden, :open, OPEN_CAP, keep: :newest)
|
|
@@ -142,7 +143,7 @@ module DaySummary
|
|
|
142
143
|
"(no savepoint yet)"
|
|
143
144
|
end
|
|
144
145
|
|
|
145
|
-
def active_sessions(store, session, now:, ttl:)
|
|
146
|
+
def active_sessions(store, session, now:, ttl:, home: nil)
|
|
146
147
|
tmp_root = SessionLedger.tmp_root(store)
|
|
147
148
|
return [] unless File.directory?(tmp_root)
|
|
148
149
|
|
|
@@ -155,7 +156,7 @@ module DaySummary
|
|
|
155
156
|
age = heartbeat_age(dir, now)
|
|
156
157
|
next if age.nil? || age > ttl
|
|
157
158
|
|
|
158
|
-
"- #{sid} (#{(age / 60).floor}m ago, on #{
|
|
159
|
+
"- #{sid} (#{(age / 60).floor}m ago, on #{delivering_label(store, home, sid, now)})"
|
|
159
160
|
end
|
|
160
161
|
rescue SystemCallError
|
|
161
162
|
[]
|
|
@@ -178,14 +179,21 @@ module DaySummary
|
|
|
178
179
|
nil
|
|
179
180
|
end
|
|
180
181
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
182
|
+
# The delivering intent's id, or "day ledger" (344 n2, D4-D7): the
|
|
183
|
+
# basename before "--" of ActiveDelivery.resolve_by_short_session's
|
|
184
|
+
# match, else the session records into today's day ledger.
|
|
185
|
+
def delivering_label(store, home, sid, now)
|
|
186
|
+
return "day ledger" if home.to_s.empty?
|
|
187
|
+
|
|
188
|
+
dir = ActiveDelivery.resolve_by_short_session(
|
|
189
|
+
global_store: store,
|
|
190
|
+
project_roots: ActiveDelivery.project_roots(home),
|
|
191
|
+
short_session: sid,
|
|
192
|
+
now: now
|
|
193
|
+
)
|
|
194
|
+
dir ? File.basename(dir).split("--", 2).first : "day ledger"
|
|
195
|
+
rescue StandardError
|
|
196
|
+
"day ledger"
|
|
189
197
|
end
|
|
190
198
|
|
|
191
199
|
# --- rendering, pure -------------------------------------------------------------
|
|
@@ -15,21 +15,12 @@ require_relative "session_ledger"
|
|
|
15
15
|
# heartbeat is only refreshed on prompts and edits, so an old heartbeat is evidence, not
|
|
16
16
|
# proof, that the session is gone. Fail-open: an unreadable directory is skipped, never
|
|
17
17
|
# raised. Depends on the including class for `plastic_home` and `check`.
|
|
18
|
+
#
|
|
19
|
+
# Intent 344 (G11, D10): the third check this category used to carry,
|
|
20
|
+
# `no_pointer_session_tmp`, is retired along with the per-session state it read.
|
|
18
21
|
module DoctorSessionLedger
|
|
19
22
|
ORPHAN_TTL_SECONDS = 24 * 60 * 60
|
|
20
23
|
|
|
21
|
-
# Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
|
|
22
|
-
# DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
|
|
23
|
-
# session start never ran at all (a `-p` print session, a resumed
|
|
24
|
-
# background job, an unregistered SessionStart hook), not a session that
|
|
25
|
-
# ran and then never closed. No choice of session id ever creates a
|
|
26
|
-
# pointer for that class, so it would otherwise stay invisible for the
|
|
27
|
-
# full 24 hours (or forever, once hook-capture/hook-record stop creating
|
|
28
|
-
# it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
|
|
29
|
-
# between its own start and its first pointer write is normal and must not
|
|
30
|
-
# be flagged, but that window is seconds, not hours.
|
|
31
|
-
NO_POINTER_TTL_SECONDS = 5 * 60
|
|
32
|
-
|
|
33
24
|
# Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
|
|
34
25
|
# else that file's mtime, else the directory's mtime.
|
|
35
26
|
def heartbeat_age(dir, now)
|
|
@@ -49,7 +40,6 @@ module DoctorSessionLedger
|
|
|
49
40
|
|
|
50
41
|
store_dir = File.join(plastic_home, "store")
|
|
51
42
|
orphans = orphaned_session_dirs(store_dir, now)
|
|
52
|
-
no_pointer = no_pointer_session_dirs(store_dir, now)
|
|
53
43
|
shape = day_ledger_shape_problems(store_dir)
|
|
54
44
|
|
|
55
45
|
checks = []
|
|
@@ -67,20 +57,6 @@ module DoctorSessionLedger
|
|
|
67
57
|
"session is gone: a live session rewrites its heartbeat on every " \
|
|
68
58
|
"prompt and edit, so only a listed directory may be removed, by hand.")
|
|
69
59
|
end
|
|
70
|
-
checks << if no_pointer.empty?
|
|
71
|
-
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
|
|
72
|
-
message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
|
|
73
|
-
"longer than #{NO_POINTER_TTL_SECONDS} seconds")
|
|
74
|
-
else
|
|
75
|
-
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
|
|
76
|
-
message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
|
|
77
|
-
"with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
|
|
78
|
-
"(session start never ran for this session)",
|
|
79
|
-
details: no_pointer, fixable: true,
|
|
80
|
-
fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
|
|
81
|
-
"session never started: no `current` pointer means session start never " \
|
|
82
|
-
"ran for it, so this is not a live session missing a checklist entry.")
|
|
83
|
-
end
|
|
84
60
|
checks << if shape.empty?
|
|
85
61
|
check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
|
|
86
62
|
message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
|
|
@@ -113,31 +89,6 @@ module DoctorSessionLedger
|
|
|
113
89
|
[] # unreadable .tmp/: nothing to report, never a crash
|
|
114
90
|
end
|
|
115
91
|
|
|
116
|
-
# Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
|
|
117
|
-
# NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
|
|
118
|
-
# that one is age-only and blind to whether a pointer exists at all, so a
|
|
119
|
-
# young no-pointer dir (a session between its start and its first pointer
|
|
120
|
-
# write) must not appear here even though it may well appear there once it
|
|
121
|
-
# ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
|
|
122
|
-
# and a dir can legitimately show up in neither, either, or both.
|
|
123
|
-
def no_pointer_session_dirs(store_dir, now)
|
|
124
|
-
tmp_root = SessionLedger.tmp_root(store_dir)
|
|
125
|
-
return [] unless File.directory?(tmp_root)
|
|
126
|
-
|
|
127
|
-
Dir.children(tmp_root).sort.filter_map do |name|
|
|
128
|
-
dir = File.join(tmp_root, name)
|
|
129
|
-
next unless File.directory?(dir)
|
|
130
|
-
next if File.exist?(File.join(dir, "current"))
|
|
131
|
-
|
|
132
|
-
age = heartbeat_age(dir, now)
|
|
133
|
-
next if age <= NO_POINTER_TTL_SECONDS
|
|
134
|
-
|
|
135
|
-
"global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
|
|
136
|
-
end
|
|
137
|
-
rescue SystemCallError
|
|
138
|
-
[] # unreadable .tmp/: nothing to report, never a crash
|
|
139
|
-
end
|
|
140
|
-
|
|
141
92
|
def day_ledger_shape_problems(store_dir)
|
|
142
93
|
root = SessionLedger.sessions_root(store_dir)
|
|
143
94
|
return [] unless File.directory?(root)
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "open3"
|
|
5
|
-
require_relative "bridge"
|
|
6
5
|
require_relative "arm"
|
|
7
6
|
require_relative "lock"
|
|
8
7
|
require_relative "worktree"
|
|
@@ -29,8 +28,8 @@ require_relative "scaffold_intent"
|
|
|
29
28
|
# fail-open, see the merge-verification note below), `finisher:` (defaults to
|
|
30
29
|
# `Worktree.method(:finish)`), `status_checker:` (defaults to a `git status --porcelain`
|
|
31
30
|
# lambda). Every seam exists so
|
|
32
|
-
# tests run with no real git, no real worktree removal, and no real
|
|
33
|
-
# tmpdir.
|
|
31
|
+
# tests run with no real git, no real worktree removal, and no real delivery-lock file
|
|
32
|
+
# outside a tmpdir.
|
|
34
33
|
#
|
|
35
34
|
# MERGE VERIFICATION (delivered disposition only). `Worktree.merge_branch` is deliberately
|
|
36
35
|
# fail-open: on a conflict it runs `git merge --abort`, warns, and returns false, and
|
|
@@ -39,7 +38,7 @@ require_relative "scaffold_intent"
|
|
|
39
38
|
# module never trusts the discarded return value; it asks git directly, AFTER finish, whether
|
|
40
39
|
# the intent's code branch is now an ancestor of the repo's current HEAD
|
|
41
40
|
# (`merged_into_current_branch?` below). `repo` and `branch` are captured BEFORE the finisher
|
|
42
|
-
# runs, because finish deletes the
|
|
41
|
+
# runs, because finish deletes the delivery's `worktree` block.
|
|
43
42
|
#
|
|
44
43
|
# DOES NOT RUN A TEST SUITE, in any form. `verify-intent` owns test execution; one concern
|
|
45
44
|
# per script (spec D8).
|
|
@@ -60,6 +59,10 @@ module ExecWorktree
|
|
|
60
59
|
# [stdout, stderr, status] triple Open3.capture3 returns (status responds to
|
|
61
60
|
# success?). A raised error (git missing, corrupt repo) is caught and reported as a
|
|
62
61
|
# nil status, so the caller can fail closed the same way a failed exit status would.
|
|
62
|
+
def blank?(value)
|
|
63
|
+
value.nil? || value.to_s.strip.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
63
66
|
def default_status_checker
|
|
64
67
|
lambda do |worktree_code|
|
|
65
68
|
Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
|
|
@@ -85,7 +88,7 @@ module ExecWorktree
|
|
|
85
88
|
# `<repo>/.claude/worktrees/<name>` (scripts/lib/worktree.rb:75), so walking up three
|
|
86
89
|
# directories recovers `<repo>` deterministically, with no I/O and no git.
|
|
87
90
|
def repo_from_worktree_code(worktree_code)
|
|
88
|
-
return nil if
|
|
91
|
+
return nil if blank?(worktree_code)
|
|
89
92
|
File.dirname(File.dirname(File.dirname(worktree_code.to_s)))
|
|
90
93
|
end
|
|
91
94
|
|
|
@@ -94,7 +97,7 @@ module ExecWorktree
|
|
|
94
97
|
# i.e. the merge actually landed. Fails CLOSED (not merged) on a blank repo/branch or a
|
|
95
98
|
# raising runner, so an inconclusive check is never reported as a success.
|
|
96
99
|
def merged_into_current_branch?(runner, repo:, branch:)
|
|
97
|
-
return false if
|
|
100
|
+
return false if blank?(repo) || blank?(branch)
|
|
98
101
|
runner.run("-C", repo, "merge-base", "--is-ancestor", branch, "HEAD").success?
|
|
99
102
|
rescue StandardError
|
|
100
103
|
false
|
|
@@ -106,12 +109,12 @@ module ExecWorktree
|
|
|
106
109
|
# The lock-owner fallback is correct here: this is a teardown step on an intent this
|
|
107
110
|
# session has been delivering.
|
|
108
111
|
def resolve_session(explicit, env_session, intent_dir)
|
|
109
|
-
return explicit.to_s.strip unless
|
|
110
|
-
return env_session.to_s.strip unless
|
|
112
|
+
return explicit.to_s.strip unless blank?(explicit)
|
|
113
|
+
return env_session.to_s.strip unless blank?(env_session)
|
|
111
114
|
lock = Lock.read(intent_dir)
|
|
112
115
|
return nil unless lock
|
|
113
116
|
owner = lock["owner_session"].to_s
|
|
114
|
-
|
|
117
|
+
blank?(owner) ? nil : owner
|
|
115
118
|
end
|
|
116
119
|
|
|
117
120
|
# --- result builders (the exit_code/stdout/stderr shape the thin CLIs share) --------
|
|
@@ -181,7 +184,7 @@ module ExecWorktree
|
|
|
181
184
|
merge_line =
|
|
182
185
|
if disposition != "delivered"
|
|
183
186
|
"not attempted (abandoned)"
|
|
184
|
-
elsif
|
|
187
|
+
elsif blank?(target_branch)
|
|
185
188
|
"merged (integration target branch could not be resolved)"
|
|
186
189
|
else
|
|
187
190
|
"merged into #{target_branch}"
|
|
@@ -202,10 +205,10 @@ module ExecWorktree
|
|
|
202
205
|
runner: Worktree::ShellRunner.new,
|
|
203
206
|
finisher: Worktree.method(:finish),
|
|
204
207
|
status_checker: default_status_checker)
|
|
205
|
-
return usage_result("--store is required") if
|
|
206
|
-
return usage_result("--id is required") if
|
|
207
|
-
return usage_result("--home is required") if
|
|
208
|
-
return usage_result("--disposition is required") if
|
|
208
|
+
return usage_result("--store is required") if blank?(store)
|
|
209
|
+
return usage_result("--id is required") if blank?(id)
|
|
210
|
+
return usage_result("--home is required") if blank?(home)
|
|
211
|
+
return usage_result("--disposition is required") if blank?(disposition)
|
|
209
212
|
unless DISPOSITIONS.include?(disposition)
|
|
210
213
|
return usage_result("--disposition must be one of #{DISPOSITIONS.join('|')} (got #{disposition.inspect})")
|
|
211
214
|
end
|
|
@@ -220,16 +223,16 @@ module ExecWorktree
|
|
|
220
223
|
|
|
221
224
|
# Step 0: session resolution + pre-flight refusal (D3-mirroring; see resolve_session).
|
|
222
225
|
key_session = resolve_session(session, env_session, intent_dir)
|
|
223
|
-
if
|
|
226
|
+
if blank?(key_session) && File.exist?(Lock.path(intent_dir))
|
|
224
227
|
return deny_result(EXIT_UNRESOLVED, no_session_message(intent_dir))
|
|
225
228
|
end
|
|
226
229
|
|
|
227
|
-
|
|
228
|
-
worktree_code =
|
|
229
|
-
return ok_result(nothing_provisioned_message(intent_dir)) if
|
|
230
|
+
delivery = Arm.delivery(intent_dir: intent_dir, home: normalized_home)
|
|
231
|
+
worktree_code = delivery.dig("worktree", "code")
|
|
232
|
+
return ok_result(nothing_provisioned_message(intent_dir)) if blank?(worktree_code)
|
|
230
233
|
|
|
231
|
-
branch =
|
|
232
|
-
# Captured now, before `finisher.call` below (Worktree.finish) deletes the
|
|
234
|
+
branch = delivery.dig("worktree", "code_branch")
|
|
235
|
+
# Captured now, before `finisher.call` below (Worktree.finish) deletes the delivery's
|
|
233
236
|
# `worktree` block, so the post-finish merge verification (BLOCKING 1/2) still has
|
|
234
237
|
# both `repo` and `branch` to work with.
|
|
235
238
|
repo = repo_from_worktree_code(worktree_code)
|
|
@@ -254,7 +257,7 @@ module ExecWorktree
|
|
|
254
257
|
# disposition only, whether the worktree directory still exists on disk (teardown
|
|
255
258
|
# incomplete); second, for a delivered disposition only, whether the branch actually
|
|
256
259
|
# merged (BLOCKING 1/2, see merged_into_current_branch? above).
|
|
257
|
-
finisher.call(
|
|
260
|
+
finisher.call(delivery, home: normalized_home, runner: runner,
|
|
258
261
|
merge: disposition == "delivered")
|
|
259
262
|
|
|
260
263
|
removed = !Dir.exist?(worktree_code)
|