@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
|
@@ -6,7 +6,7 @@ require "yaml"
|
|
|
6
6
|
require_relative "ready_set"
|
|
7
7
|
require_relative "node_ledger"
|
|
8
8
|
require_relative "node_file"
|
|
9
|
-
require_relative "
|
|
9
|
+
require_relative "node_input"
|
|
10
10
|
require_relative "node_worktree"
|
|
11
11
|
require_relative "work_graph_validator"
|
|
12
12
|
require_relative "runner_core"
|
|
@@ -17,12 +17,12 @@ require_relative "guarded_append"
|
|
|
17
17
|
require_relative "harness_adapter"
|
|
18
18
|
|
|
19
19
|
# RunnerDispatch (intent 340, G7, n5): validates the graph, computes the
|
|
20
|
-
# ready set, applies RunnerPolicy, mints leases, builds
|
|
20
|
+
# ready set, applies RunnerPolicy, mints leases, builds node inputs, writes
|
|
21
21
|
# `running`, and returns the dispatch plan `step` prints. Never spawns an
|
|
22
22
|
# agent itself (327 D42): the session does that from the plan this returns.
|
|
23
23
|
#
|
|
24
24
|
# Pure and dependency-injected down to the clock: every side effect - the
|
|
25
|
-
# full validator, the ready-set analyzer, the
|
|
25
|
+
# full validator, the ready-set analyzer, the input builder, the worktree
|
|
26
26
|
# module, the ledger write, git itself - is an injectable keyword argument
|
|
27
27
|
# with a real default, so a test never touches a real repository or a real
|
|
28
28
|
# filesystem outside its own tmpdir.
|
|
@@ -32,7 +32,7 @@ module RunnerDispatch
|
|
|
32
32
|
DEFAULT_LIMIT = 2
|
|
33
33
|
|
|
34
34
|
# The return-schema instruction (327 D5): rides in the dispatch PLAN, never
|
|
35
|
-
# inside the
|
|
35
|
+
# inside the node input, so `input=<sha>` keeps naming a reproducible input
|
|
36
36
|
# (matrix row 5.23). NodeReturn.parse (n4) is this text's implementation.
|
|
37
37
|
RETURN_CONTRACT = <<~TEXT.freeze
|
|
38
38
|
RETURN CONTRACT: reply with exactly one YAML document as your final
|
|
@@ -52,13 +52,13 @@ module RunnerDispatch
|
|
|
52
52
|
SPAWN_AGENT = "plastic-executor"
|
|
53
53
|
|
|
54
54
|
# matrix 6.1/6.2: one spawn block per dispatched node - agent, the model
|
|
55
|
-
# RunnerPolicy.model_for resolved, the
|
|
56
|
-
# (
|
|
55
|
+
# RunnerPolicy.model_for resolved, the node input path, the one test command
|
|
56
|
+
# (NodeInput.test_command_block, n4), and the call cap (n2) - fenced so a
|
|
57
57
|
# session pastes it straight into the Agent tool (327 D42: the runner
|
|
58
58
|
# itself never spawns).
|
|
59
|
-
def spawn_block(model:,
|
|
60
|
-
lines = ["agent: #{agent}", "model: #{model}", "
|
|
61
|
-
|
|
59
|
+
def spawn_block(model:, input:, test_command:, call_cap:, agent: SPAWN_AGENT)
|
|
60
|
+
lines = ["agent: #{agent}", "model: #{model}", "input: #{input}", test_command,
|
|
61
|
+
NodeInput.call_cap_sentence(call_cap)]
|
|
62
62
|
(["```"] + lines + ["```"]).join("\n")
|
|
63
63
|
end
|
|
64
64
|
|
|
@@ -69,7 +69,7 @@ module RunnerDispatch
|
|
|
69
69
|
def dispatch(context, limit: DEFAULT_LIMIT, now: Time.now, config: {}, harness: nil, caps: ReadySet::DEFAULT_CAPS,
|
|
70
70
|
validator: WorkGraphValidator.method(:validate),
|
|
71
71
|
ready_analyzer: ReadySet.method(:analyze),
|
|
72
|
-
|
|
72
|
+
input_builder: NodeInput.method(:build),
|
|
73
73
|
worktree: NodeWorktree,
|
|
74
74
|
ledger: NodeLedger,
|
|
75
75
|
runner: Worktree::ShellRunner.new,
|
|
@@ -132,7 +132,7 @@ module RunnerDispatch
|
|
|
132
132
|
dispatched = []
|
|
133
133
|
parked = []
|
|
134
134
|
stop = nil
|
|
135
|
-
|
|
135
|
+
input_failures = []
|
|
136
136
|
ceiling_blocked = false
|
|
137
137
|
|
|
138
138
|
analysis[:ranked_ready].each do |row|
|
|
@@ -172,10 +172,10 @@ module RunnerDispatch
|
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
result = dispatch_one(context, node: node, kind: kind, now: now, config: config, harness: harness_key,
|
|
175
|
-
caps: caps, edges: edges, nodes_decl: nodes_decl,
|
|
175
|
+
caps: caps, edges: edges, nodes_decl: nodes_decl, input_builder: input_builder,
|
|
176
176
|
worktree: worktree, ledger: ledger, runner: runner)
|
|
177
|
-
if result[:
|
|
178
|
-
|
|
177
|
+
if result[:input_build_failed]
|
|
178
|
+
input_failures << result
|
|
179
179
|
next
|
|
180
180
|
end
|
|
181
181
|
next unless result[:ok]
|
|
@@ -192,7 +192,7 @@ module RunnerDispatch
|
|
|
192
192
|
RunnerCore.render_status(context) if dispatched.any? || stop || parked.any?
|
|
193
193
|
|
|
194
194
|
build_report(context: context, dispatched: dispatched, stop: stop, parked: parked,
|
|
195
|
-
ceiling_blocked: ceiling_blocked,
|
|
195
|
+
ceiling_blocked: ceiling_blocked, input_failures: input_failures, running_count: running_count,
|
|
196
196
|
harness: harness_key)
|
|
197
197
|
end
|
|
198
198
|
|
|
@@ -212,9 +212,9 @@ module RunnerDispatch
|
|
|
212
212
|
end
|
|
213
213
|
private_class_method :safe_analyze
|
|
214
214
|
|
|
215
|
-
# --- one node's whole dispatch (
|
|
215
|
+
# --- one node's whole dispatch (node input, lease, `running`) -------------------
|
|
216
216
|
|
|
217
|
-
def dispatch_one(context, node:, kind:, now:, config:, harness:, caps:, edges:, nodes_decl:,
|
|
217
|
+
def dispatch_one(context, node:, kind:, now:, config:, harness:, caps:, edges:, nodes_decl:, input_builder:,
|
|
218
218
|
worktree:, ledger:, runner:)
|
|
219
219
|
intent_dir = context.intent_dir
|
|
220
220
|
savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
|
|
@@ -231,7 +231,7 @@ module RunnerDispatch
|
|
|
231
231
|
pre_existing_worktree = worktree_pre_existing?(worktree, context, node, kind)
|
|
232
232
|
|
|
233
233
|
# Row 5.16/5.29: only a `work` node gets a worktree, and this is the
|
|
234
|
-
# node-scoped `worktree_reader:` D23 injects into
|
|
234
|
+
# node-scoped `worktree_reader:` D23 injects into NodeInput.build - it
|
|
235
235
|
# names THIS node's own worktree and branch, never the intent's.
|
|
236
236
|
provisioned = RunnerPolicy.worktree?(kind) ? worktree.provision(context, node: node, kind: kind, runner: runner)
|
|
237
237
|
: unprovisioned
|
|
@@ -239,26 +239,26 @@ module RunnerDispatch
|
|
|
239
239
|
{ "code" => provisioned[:path], "code_branch" => provisioned[:branch], "provisioned" => !!provisioned[:provisioned] }
|
|
240
240
|
end
|
|
241
241
|
|
|
242
|
-
# Row 5.18: build the
|
|
243
|
-
# naming bytes that do not exist yet is worse than a
|
|
242
|
+
# Row 5.18: build the node input BEFORE writing `running` - a `running` line
|
|
243
|
+
# naming bytes that do not exist yet is worse than a node input nobody reads.
|
|
244
244
|
# Row 5.30: `force: true` always - a fresh attempt number this dispatch
|
|
245
245
|
# computes is, by construction, never one `running` has already claimed,
|
|
246
246
|
# so an existing file at that path is always an orphan from a step that
|
|
247
|
-
# crashed between building the
|
|
247
|
+
# crashed between building the node input and writing `running`, safe to
|
|
248
248
|
# overwrite outright.
|
|
249
249
|
# Row 5.20/10.8: the node's own declared budget: (M7) - nil when the node
|
|
250
|
-
# names none, in which case
|
|
250
|
+
# names none, in which case NodeInput.build falls back to its own
|
|
251
251
|
# default (row 10.9).
|
|
252
|
-
build_result =
|
|
252
|
+
build_result = input_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
|
|
253
253
|
model: model, force: true, worktree_reader: node_reader,
|
|
254
254
|
budget_tokens: node_declared_budget(intent_dir, node), call_cap: calls_cap)
|
|
255
255
|
unless build_result[:ok]
|
|
256
|
-
# M6: a failed
|
|
256
|
+
# M6: a failed input build never leaves an orphan worktree behind, and
|
|
257
257
|
# its errors travel back up so the step's report can name the node and
|
|
258
258
|
# the reason instead of a bare "stalled" (row 10.6/10.7).
|
|
259
|
-
rollback_dispatch(context, node: node, kind: kind,
|
|
259
|
+
rollback_dispatch(context, node: node, kind: kind, input_path: build_result[:path], runner: runner,
|
|
260
260
|
worktree: worktree, created_this_dispatch: !pre_existing_worktree)
|
|
261
|
-
return { ok: false,
|
|
261
|
+
return { ok: false, input_build_failed: true, node: node, errors: build_result[:errors] }
|
|
262
262
|
end
|
|
263
263
|
|
|
264
264
|
precondition = lambda do |c|
|
|
@@ -267,7 +267,7 @@ module RunnerDispatch
|
|
|
267
267
|
# Row 1.19/1.20/D21: harness= rides alongside model= on every `running`
|
|
268
268
|
# line, resolved once by the caller through HarnessAdapter and threaded
|
|
269
269
|
# straight through here - never re-resolved, never a literal.
|
|
270
|
-
fields = { holder: holder, expires: expires,
|
|
270
|
+
fields = { holder: holder, expires: expires, input: build_result[:sha], model: model, harness: harness,
|
|
271
271
|
calls: calls_cap }
|
|
272
272
|
|
|
273
273
|
result = begin
|
|
@@ -281,18 +281,18 @@ module RunnerDispatch
|
|
|
281
281
|
# side effects this method already produced - the node never ran, so
|
|
282
282
|
# nothing may act like it did.
|
|
283
283
|
unless result == :written
|
|
284
|
-
rollback_dispatch(context, node: node, kind: kind,
|
|
284
|
+
rollback_dispatch(context, node: node, kind: kind, input_path: build_result[:path], runner: runner,
|
|
285
285
|
worktree: worktree, created_this_dispatch: !pre_existing_worktree)
|
|
286
286
|
return { ok: false }
|
|
287
287
|
end
|
|
288
288
|
|
|
289
|
-
test_command =
|
|
290
|
-
spawn = spawn_block(model: model,
|
|
289
|
+
test_command = NodeInput.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
|
|
290
|
+
spawn = spawn_block(model: model, input: build_result[:path], test_command: test_command, call_cap: calls_cap)
|
|
291
291
|
|
|
292
292
|
{
|
|
293
293
|
ok: true,
|
|
294
294
|
entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, worktree: provisioned[:path],
|
|
295
|
-
|
|
295
|
+
input: build_result[:path], spawn: spawn },
|
|
296
296
|
}
|
|
297
297
|
end
|
|
298
298
|
|
|
@@ -331,10 +331,10 @@ module RunnerDispatch
|
|
|
331
331
|
|
|
332
332
|
# Row 10.16/M13: `created_this_dispatch:` gates the worktree half of the
|
|
333
333
|
# rollback - a worktree this call did not create (kept on disk by a prior
|
|
334
|
-
# attempt's failed_verification, D7) is never touched, only a
|
|
335
|
-
# call's own `
|
|
336
|
-
def rollback_dispatch(context, node:, kind:,
|
|
337
|
-
File.delete(
|
|
334
|
+
# attempt's failed_verification, D7) is never touched, only a node input this
|
|
335
|
+
# call's own `input_builder` may have written is ever deleted.
|
|
336
|
+
def rollback_dispatch(context, node:, kind:, input_path:, runner:, worktree:, created_this_dispatch:)
|
|
337
|
+
File.delete(input_path) if input_path && File.exist?(input_path)
|
|
338
338
|
return unless RunnerPolicy.worktree?(kind)
|
|
339
339
|
return unless created_this_dispatch
|
|
340
340
|
|
|
@@ -429,7 +429,7 @@ module RunnerDispatch
|
|
|
429
429
|
end
|
|
430
430
|
private_class_method :invalid_graph_result
|
|
431
431
|
|
|
432
|
-
def build_report(context:, dispatched:, stop:, parked:, ceiling_blocked: false,
|
|
432
|
+
def build_report(context:, dispatched:, stop:, parked:, ceiling_blocked: false, input_failures: [],
|
|
433
433
|
running_count: 0, harness: nil)
|
|
434
434
|
base = empty_result.merge(dispatched: dispatched, stop: stop, parked: parked,
|
|
435
435
|
plan: render_plan(dispatched), harness: harness)
|
|
@@ -453,21 +453,21 @@ module RunnerDispatch
|
|
|
453
453
|
if complete
|
|
454
454
|
base.merge(status: "complete")
|
|
455
455
|
else
|
|
456
|
-
# M6/row 10.7: a failed
|
|
456
|
+
# M6/row 10.7: a failed input build writes no ledger line at all,
|
|
457
457
|
# so `named_blockers` (ledger-derived) never sees it on its own -
|
|
458
458
|
# its own node and reason are named here so `stalled` never prints
|
|
459
459
|
# bare.
|
|
460
|
-
blockers = named_blockers(context) +
|
|
460
|
+
blockers = named_blockers(context) + input_failures.map { |f| input_failure_blocker(f) }
|
|
461
461
|
base.merge(status: "stalled", blockers: blockers)
|
|
462
462
|
end
|
|
463
463
|
end
|
|
464
464
|
end
|
|
465
465
|
private_class_method :build_report
|
|
466
466
|
|
|
467
|
-
def
|
|
468
|
-
"#{failure[:node]}:
|
|
467
|
+
def input_failure_blocker(failure)
|
|
468
|
+
"#{failure[:node]}: node input build failed (#{Array(failure[:errors]).join('; ')})"
|
|
469
469
|
end
|
|
470
|
-
private_class_method :
|
|
470
|
+
private_class_method :input_failure_blocker
|
|
471
471
|
|
|
472
472
|
# Row 5.25a/5.26: every unfinished node's own blockers, with ReadySet's
|
|
473
473
|
# hard-attempt-cap wording renamed so it reads as the named backstop it is
|
|
@@ -494,9 +494,9 @@ module RunnerDispatch
|
|
|
494
494
|
private_class_method :name_blocker
|
|
495
495
|
|
|
496
496
|
# Row 5.22/5.23/5.24: one machine-readable (YAML) document naming, per
|
|
497
|
-
# dispatched node, the
|
|
497
|
+
# dispatched node, the node input path, the model, the worktree, the kind and
|
|
498
498
|
# the role, plus the return contract ONCE at the top level - never inside
|
|
499
|
-
# any one node's
|
|
499
|
+
# any one node's input. Row 6.4: "spawn" carries the same, already fully
|
|
500
500
|
# rendered spawn block for each dispatched node in order, so any reader of
|
|
501
501
|
# this data (YAML today, JSON if it is ever re-serialized) finds it under
|
|
502
502
|
# `spawn` rather than re-deriving it from the other fields.
|
|
@@ -507,7 +507,7 @@ module RunnerDispatch
|
|
|
507
507
|
"return_contract" => RETURN_CONTRACT,
|
|
508
508
|
"dispatch" => dispatched.map do |d|
|
|
509
509
|
{ "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
|
|
510
|
-
"worktree" => d[:worktree], "
|
|
510
|
+
"worktree" => d[:worktree], "input" => d[:input] }
|
|
511
511
|
end,
|
|
512
512
|
"spawn" => dispatched.map { |d| d[:spawn] }
|
|
513
513
|
)
|
|
@@ -32,7 +32,7 @@ require_relative "savepoint"
|
|
|
32
32
|
# newer commits is extended instead, up to twice per attempt (row 2.7); the
|
|
33
33
|
# extension is never a ledger transition (`running` cannot re-enter `running`
|
|
34
34
|
# under the transition layer), so it is one line appended to
|
|
35
|
-
#
|
|
35
|
+
# attempts/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
|
|
36
36
|
# count (row 2.18), never trusted from the caller. A third expiry reclaims
|
|
37
37
|
# regardless of new commits.
|
|
38
38
|
#
|
|
@@ -199,15 +199,15 @@ module RunnerSweep
|
|
|
199
199
|
|
|
200
200
|
# Row 2.18: the attempt number comes from the ledger's own count of
|
|
201
201
|
# `running` lines since the subject's last terminal line - the exact same
|
|
202
|
-
# arithmetic
|
|
203
|
-
# extensions file for a `running` line always matches the
|
|
202
|
+
# arithmetic NodeInput uses to name that attempt's input file, so the
|
|
203
|
+
# extensions file for a `running` line always matches the node input it extends.
|
|
204
204
|
def current_attempt(entries, subject)
|
|
205
205
|
ReadySet.attempts_count(entries, subject)
|
|
206
206
|
end
|
|
207
207
|
private_class_method :current_attempt
|
|
208
208
|
|
|
209
209
|
def extensions_path(intent_dir, node, attempt)
|
|
210
|
-
File.join(intent_dir.to_s, "
|
|
210
|
+
File.join(intent_dir.to_s, "attempts", "#{node}--a#{attempt}.extensions")
|
|
211
211
|
end
|
|
212
212
|
private_class_method :extensions_path
|
|
213
213
|
|
|
@@ -184,7 +184,7 @@ module RunnerUntilEmpty
|
|
|
184
184
|
# stdout_io:, stderr_io:}. `harness:` is accepted for symmetry with
|
|
185
185
|
# `step_once` even though node-run itself never takes a --harness flag -
|
|
186
186
|
# the harness was already recorded on the node's own `running` line by the
|
|
187
|
-
# dispatch this call followed, and node-run reads its
|
|
187
|
+
# dispatch this call followed, and node-run reads its node input off that
|
|
188
188
|
# line, never off this argv. RUBYOPT is cleared explicitly, the same
|
|
189
189
|
# contract every other ruby-spawning site in this tree carries.
|
|
190
190
|
def spawn_node_run(context, node:, harness: nil)
|
package/scripts/lib/savepoint.rb
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# Savepoint: the intent-directory savepoint ledger and the stage derivation it
|
|
6
|
-
# rests on (intent 303
|
|
6
|
+
# rests on (intent 303). Everything here is derived from
|
|
7
7
|
# the files on disk inside one intent directory: which lifecycle files are real
|
|
8
8
|
# (not the placeholder sentinel), which stage that makes, and the append-only
|
|
9
|
-
# savepoint.md ledger that records each milestone. No session, no
|
|
9
|
+
# savepoint.md ledger that records each milestone. No session, no lock, no
|
|
10
10
|
# lock: a caller that only wants to append a ledger line or ask "which stage is
|
|
11
|
-
# this intent at" loads this file and nothing else.
|
|
12
|
-
#
|
|
13
|
-
# requires the bridge.
|
|
11
|
+
# this intent at" loads this file and nothing else. `scripts/lib/arm.rb` (arm,
|
|
12
|
+
# disarm, lock repair) requires this file; this file never requires arm.rb.
|
|
14
13
|
require "fileutils"
|
|
15
14
|
require "time" # Time#iso8601 for the ledger timestamps
|
|
16
15
|
|
|
@@ -60,7 +59,7 @@ module Savepoint
|
|
|
60
59
|
|
|
61
60
|
# Walk up from file_path; return the first ancestor that looks like an intent
|
|
62
61
|
# directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
|
|
63
|
-
# target without needing
|
|
62
|
+
# target without needing the lock or arm machinery. The input is always a file inside the intent
|
|
64
63
|
# dir (never the dir itself), so the walk-up starts at its parent.
|
|
65
64
|
def self.intent_dir_for(file_path)
|
|
66
65
|
dir = File.expand_path(file_path)
|
|
@@ -384,7 +383,7 @@ module Savepoint
|
|
|
384
383
|
|
|
385
384
|
# --- Phantom-line detection (intent 134) ------------------------------------
|
|
386
385
|
#
|
|
387
|
-
# A companion to the ledger, not a new writer: pure, disk-only, hermetic (no
|
|
386
|
+
# A companion to the ledger, not a new writer: pure, disk-only, hermetic (no lock or
|
|
388
387
|
# session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
|
|
389
388
|
# a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
|
|
390
389
|
# can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "worktree"
|
|
6
|
-
require_relative "bridge"
|
|
7
6
|
require_relative "savepoint"
|
|
8
7
|
require_relative "intent_validator"
|
|
9
8
|
|
|
@@ -21,7 +20,7 @@ require_relative "intent_validator"
|
|
|
21
20
|
# copy or a mechanical rendering of a committed artifact.
|
|
22
21
|
#
|
|
23
22
|
# Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
|
|
24
|
-
# `ENV` directly (only via the ambient `Dir.home` default, matching
|
|
23
|
+
# `ENV` directly (only via the ambient `Dir.home` default, matching this module's own
|
|
25
24
|
# convention). A git seam is injected as `runner:`, defaulting to
|
|
26
25
|
# `Worktree::ShellRunner.new`, so tests drive this in process with a fake runner and
|
|
27
26
|
# never touch real git. Every method returns a value; `scripts/scaffold-intent` maps the
|
|
@@ -29,6 +28,10 @@ require_relative "intent_validator"
|
|
|
29
28
|
module ScaffoldIntent
|
|
30
29
|
module_function
|
|
31
30
|
|
|
31
|
+
def blank?(value)
|
|
32
|
+
value.nil? || value.to_s.strip.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
32
35
|
# --- path resolution (pure) --------------------------------------------------
|
|
33
36
|
|
|
34
37
|
def expand(path)
|
|
@@ -211,7 +214,7 @@ module ScaffoldIntent
|
|
|
211
214
|
end
|
|
212
215
|
end
|
|
213
216
|
|
|
214
|
-
unless
|
|
217
|
+
unless blank?(test_summary)
|
|
215
218
|
out << "\n"
|
|
216
219
|
out << "Test summary from #{test_summary}:\n"
|
|
217
220
|
out << "```\n"
|
|
@@ -17,8 +17,9 @@ module SessionClose
|
|
|
17
17
|
NOOP_REASONS = %w[clear resume].freeze
|
|
18
18
|
|
|
19
19
|
# The default hand-off writer (intent 311, spec D6): renders this session's
|
|
20
|
-
# share of
|
|
21
|
-
# goes. The hook script builds it
|
|
20
|
+
# share of its session day (SessionLedger.session_day, graph.md D7) into
|
|
21
|
+
# handoff--<session>.md before the tmp dir goes. The hook script builds it
|
|
22
|
+
# with the shipped templates dir.
|
|
22
23
|
def default_handoff(templates)
|
|
23
24
|
lambda do |store, day, session|
|
|
24
25
|
Handoff.write(store: store, day: day, session: session, trigger: "close", templates: templates)
|
|
@@ -45,26 +46,33 @@ module SessionClose
|
|
|
45
46
|
session = SessionLedger.short_session_id(payload.is_a?(Hash) ? payload["session_id"] : nil, nil)
|
|
46
47
|
return report if session.empty?
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
days = safely { SessionLedger.session_days(store, session, today: today) } || []
|
|
50
|
+
days = [today] if days.empty?
|
|
51
|
+
session_day = days.last
|
|
49
52
|
project = "global"
|
|
50
53
|
|
|
54
|
+
# Intent 344 (G11, D13): a session that crossed midnight carries pending
|
|
55
|
+
# lines on more than one day ledger; every one of them drops, not only
|
|
56
|
+
# the newest, so a close never leaves an earlier day's lines pending.
|
|
51
57
|
report[:dropped] = safely do
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
days.sum do |day|
|
|
59
|
+
checklist = SessionLedger.checklist_path(store, day)
|
|
60
|
+
count = SessionLedger.flip_all(checklist, from: :pending, to: :dropped, session: session)
|
|
61
|
+
if count.positive?
|
|
62
|
+
SessionLedger.append_line(SessionLedger.savepoint_path(store, day),
|
|
63
|
+
SessionLedger.savepoint_line("Note", session, project,
|
|
64
|
+
"dropped #{count} pending lines at close", now: now))
|
|
65
|
+
end
|
|
66
|
+
count
|
|
58
67
|
end
|
|
59
|
-
count
|
|
60
68
|
end || 0
|
|
61
69
|
|
|
62
70
|
# The hand-off (intent 311, spec D6) is written after the drop, so it
|
|
63
|
-
# reflects it, and before the tmp dir goes
|
|
64
|
-
#
|
|
71
|
+
# reflects it, and before the tmp dir goes. A session with no line in the
|
|
72
|
+
# day ledger's window falls back to today, the same as the drop above.
|
|
65
73
|
if handoff
|
|
66
74
|
report[:handoff] = safely do
|
|
67
|
-
handoff.call(store,
|
|
75
|
+
handoff.call(store, session_day, session)
|
|
68
76
|
true
|
|
69
77
|
end || false
|
|
70
78
|
end
|
|
@@ -75,27 +83,21 @@ module SessionClose
|
|
|
75
83
|
true
|
|
76
84
|
end || false
|
|
77
85
|
|
|
78
|
-
|
|
86
|
+
# One carry spawns per day earlier than today, oldest first; a single
|
|
87
|
+
# earlier day keeps reporting its own arg array (not a one-element
|
|
88
|
+
# list) so an existing caller reading report[:spawned] as one array is
|
|
89
|
+
# unaffected.
|
|
90
|
+
earlier_days = days.select { |day| day < today }
|
|
91
|
+
unless earlier_days.empty?
|
|
79
92
|
report[:spawned] = safely do
|
|
80
|
-
|
|
81
|
-
spawner.call(args)
|
|
82
|
-
|
|
93
|
+
arg_lists = earlier_days.map { |day| ["--day", day, "--carry-to", today, "--store", store] }
|
|
94
|
+
arg_lists.each { |args| spawner.call(args) }
|
|
95
|
+
arg_lists.length == 1 ? arg_lists.first : arg_lists
|
|
83
96
|
end
|
|
84
97
|
end
|
|
85
98
|
report
|
|
86
99
|
end
|
|
87
100
|
|
|
88
|
-
# The pointer names the intent this session records into; a day id means
|
|
89
|
-
# the day ledger. Anything else (an intent id) means an auto team owns the
|
|
90
|
-
# record and the close touches no day ledger.
|
|
91
|
-
def read_pointer_day(store, session)
|
|
92
|
-
path = SessionLedger.pointer_path(store, session)
|
|
93
|
-
return nil unless File.exist?(path)
|
|
94
|
-
|
|
95
|
-
value = File.read(path).strip
|
|
96
|
-
SessionLedger.valid_day_id?(value) ? value : nil
|
|
97
|
-
end
|
|
98
|
-
|
|
99
101
|
def safely
|
|
100
102
|
yield
|
|
101
103
|
rescue StandardError
|
|
@@ -5,6 +5,7 @@ require "yaml"
|
|
|
5
5
|
require_relative "worktree"
|
|
6
6
|
require_relative "scaffold_intent"
|
|
7
7
|
require_relative "session_ledger"
|
|
8
|
+
require_relative "active_delivery"
|
|
8
9
|
|
|
9
10
|
# SessionGit - the branch model and per-repo flow settings behind
|
|
10
11
|
# `scripts/session-commit` (intent 300), which is how a checklist item
|
|
@@ -185,24 +186,29 @@ module SessionGit
|
|
|
185
186
|
text.to_s.downcase.scan(/[a-z0-9]+/).first(max_words).join("-")
|
|
186
187
|
end
|
|
187
188
|
|
|
188
|
-
# The intent
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
|
|
193
|
-
# not an intent name, and falls back to the day id here too, which is the
|
|
194
|
-
# same value either way.
|
|
195
|
-
def resolve_ticket(day:, store:, session:, ticket_source:)
|
|
189
|
+
# The delivering intent's id when `ticket_source` is `intent_id`, else the
|
|
190
|
+
# day id (344 n2, D4-D7, D12). `resolver` is injected so tests need no
|
|
191
|
+
# real ~/.plastic; it defaults to ActiveDelivery.resolve_by_short_session
|
|
192
|
+
# against the store's own project roots.
|
|
193
|
+
def resolve_ticket(day:, store:, session:, ticket_source:, resolver: nil)
|
|
196
194
|
return day.to_s unless ticket_source.to_s == "intent_id"
|
|
197
195
|
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
resolver ||= lambda { |short_session|
|
|
197
|
+
ActiveDelivery.resolve_by_short_session(
|
|
198
|
+
global_store: store,
|
|
199
|
+
project_roots: ActiveDelivery.project_roots(File.dirname(store)),
|
|
200
|
+
short_session: short_session
|
|
201
|
+
)
|
|
202
|
+
}
|
|
200
203
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
dir = begin
|
|
205
|
+
resolver.call(session)
|
|
206
|
+
rescue StandardError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
return day.to_s if dir.to_s.empty?
|
|
204
210
|
|
|
205
|
-
|
|
211
|
+
File.basename(dir).split("--", 2).first
|
|
206
212
|
end
|
|
207
213
|
|
|
208
214
|
# --- commit message ------------------------------------------------------------
|
|
@@ -154,14 +154,54 @@ module SessionLedger
|
|
|
154
154
|
File.join(tmp_root(store), session_id)
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
def pointer_path(store, session_id)
|
|
158
|
-
File.join(session_tmp_dir(store, session_id), "current")
|
|
159
|
-
end
|
|
160
|
-
|
|
161
157
|
def heartbeat_path(store, session_id)
|
|
162
158
|
File.join(session_tmp_dir(store, session_id), "heartbeat")
|
|
163
159
|
end
|
|
164
160
|
|
|
161
|
+
# --- Session day (spec D7 replacement for the retired pointer) ------------
|
|
162
|
+
|
|
163
|
+
# Every day, ascending, no later than `today` and no earlier than `today`
|
|
164
|
+
# minus `window_days`, whose checklist carries at least one line tagged to
|
|
165
|
+
# `session` (any state). Each day's checklist is read inside its own
|
|
166
|
+
# rescue, so one unreadable or missing checklist skips that day only and
|
|
167
|
+
# never gives up on a later one. Returns an empty array when no day in the
|
|
168
|
+
# window carries the session's line, and on any error reading the store
|
|
169
|
+
# (never raises).
|
|
170
|
+
def session_days(store, session, today:, window_days: 7)
|
|
171
|
+
root = sessions_root(store)
|
|
172
|
+
return [] unless File.directory?(root)
|
|
173
|
+
|
|
174
|
+
today_date = Date.strptime(today, "%Y%m%d")
|
|
175
|
+
floor_date = today_date - window_days
|
|
176
|
+
|
|
177
|
+
days = Dir.children(root).select { |name| valid_day_id?(name) }.select do |name|
|
|
178
|
+
date = Date.strptime(name, "%Y%m%d")
|
|
179
|
+
date >= floor_date && date <= today_date
|
|
180
|
+
end.sort
|
|
181
|
+
|
|
182
|
+
days.select do |day|
|
|
183
|
+
begin
|
|
184
|
+
read_locked(checklist_path(store, day)).each_line.any? do |line|
|
|
185
|
+
parsed = parse_checklist_line(line)
|
|
186
|
+
parsed && parsed[:session] == session
|
|
187
|
+
end
|
|
188
|
+
rescue StandardError
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
rescue StandardError
|
|
193
|
+
[]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The newest day in `session_days`, the day this session's close and
|
|
197
|
+
# hand-off write belong to (intent 344, G11, D13: a session that crosses
|
|
198
|
+
# midnight carries lines on more than one day, and the newest is the one
|
|
199
|
+
# still open). Falls back to `today` when no day in the window carries the
|
|
200
|
+
# session's line.
|
|
201
|
+
def session_day(store, session, today:, window_days: 7)
|
|
202
|
+
session_days(store, session, today: today, window_days: window_days).last || today
|
|
203
|
+
end
|
|
204
|
+
|
|
165
205
|
# Create `.tmp/` plus a `.gitignore` holding exactly `*`, if that file does
|
|
166
206
|
# not already exist. The store is a local git repo that auto-commits
|
|
167
207
|
# `add -A`, so without this ignore file every heartbeat write would enter
|