@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.
Files changed (63) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-node-research.md +4 -4
  3. package/agents/plastic-node-verify.md +4 -4
  4. package/agents/plastic-node-work.md +4 -4
  5. package/package.json +1 -1
  6. package/scripts/dashboard.rb +1 -1
  7. package/scripts/end-intent +19 -16
  8. package/scripts/exec-worktree +5 -5
  9. package/scripts/hook-call-budget +6 -6
  10. package/scripts/hook-capture +15 -15
  11. package/scripts/hook-record +21 -14
  12. package/scripts/hook-savepoint +2 -1
  13. package/scripts/hook-session-start +9 -10
  14. package/scripts/lib/active_delivery.rb +44 -0
  15. package/scripts/lib/arm.rb +41 -102
  16. package/scripts/lib/codex_adapter.rb +2 -2
  17. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  18. package/scripts/lib/day_summary.rb +19 -11
  19. package/scripts/lib/doctor_session_ledger.rb +3 -52
  20. package/scripts/lib/exec_worktree.rb +24 -21
  21. package/scripts/lib/graph_measure_budget.rb +29 -28
  22. package/scripts/lib/handoff.rb +4 -8
  23. package/scripts/lib/harness_adapter.rb +6 -6
  24. package/scripts/lib/index_entry.rb +53 -0
  25. package/scripts/lib/insights.rb +1 -1
  26. package/scripts/lib/installer_core.rb +120 -7
  27. package/scripts/lib/lock.rb +8 -9
  28. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  29. package/scripts/lib/node_input_compatibility.rb +62 -0
  30. package/scripts/lib/node_ledger.rb +4 -2
  31. package/scripts/lib/outcome_report.rb +1 -1
  32. package/scripts/lib/project_config.rb +45 -0
  33. package/scripts/lib/ready_set.rb +1 -1
  34. package/scripts/lib/roadmap_savepoint.rb +1 -1
  35. package/scripts/lib/runner_absorb.rb +5 -5
  36. package/scripts/lib/runner_dispatch.rb +44 -44
  37. package/scripts/lib/runner_sweep.rb +4 -4
  38. package/scripts/lib/runner_until_empty.rb +1 -1
  39. package/scripts/lib/savepoint.rb +6 -7
  40. package/scripts/lib/scaffold_intent.rb +6 -3
  41. package/scripts/lib/session_close.rb +30 -28
  42. package/scripts/lib/session_git.rb +20 -14
  43. package/scripts/lib/session_ledger.rb +44 -4
  44. package/scripts/lib/worktree.rb +24 -24
  45. package/scripts/lib/worktree_sweep.rb +3 -3
  46. package/scripts/{node-packet → node-input} +15 -15
  47. package/scripts/node-run +19 -19
  48. package/scripts/plastic-lock +33 -32
  49. package/scripts/runner +1 -1
  50. package/skills/auto/SKILL.md +7 -7
  51. package/skills/auto/references/end-tail.md +9 -11
  52. package/skills/conventions/references/completion-and-done.md +9 -10
  53. package/skills/conventions/references/knowledge-graph.md +1 -1
  54. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  55. package/skills/direct/SKILL.md +2 -2
  56. package/skills/doctor/SKILL.md +1 -1
  57. package/skills/intent-executing/SKILL.md +1 -1
  58. package/skills/releasing/SKILL.md +2 -2
  59. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  60. package/skills/releasing/references/release-lines.md +1 -1
  61. package/templates/index.md +1 -1
  62. package/templates/project.yml +1 -1
  63. 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 "node_packet"
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 packets, writes
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 packet builder, the worktree
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 packet, so `packet=<sha>` keeps naming a reproducible input
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 packet path, the one test command
56
- # (NodePacket.test_command_block, n4), and the call cap (n2) - fenced so a
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:, packet:, test_command:, call_cap:, agent: SPAWN_AGENT)
60
- lines = ["agent: #{agent}", "model: #{model}", "packet: #{packet}", test_command,
61
- NodePacket.call_cap_sentence(call_cap)]
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
- packet_builder: NodePacket.method(:build),
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
- packet_failures = []
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, packet_builder: packet_builder,
175
+ caps: caps, edges: edges, nodes_decl: nodes_decl, input_builder: input_builder,
176
176
  worktree: worktree, ledger: ledger, runner: runner)
177
- if result[:packet_build_failed]
178
- packet_failures << result
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, packet_failures: packet_failures, running_count: running_count,
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 (packet, lease, `running`) -------------------
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:, packet_builder:,
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 NodePacket.build - it
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 packet BEFORE writing `running` - a `running` line
243
- # naming bytes that do not exist yet is worse than a packet nobody reads.
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 packet and writing `running`, safe to
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 NodePacket.build falls back to its own
250
+ # names none, in which case NodeInput.build falls back to its own
251
251
  # default (row 10.9).
252
- build_result = packet_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
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 packet build never leaves an orphan worktree behind, and
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, packet_path: build_result[:path], runner: runner,
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, packet_build_failed: true, node: node, errors: build_result[:errors] }
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, packet: build_result[:sha], model: model, harness: harness,
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, packet_path: build_result[:path], runner: runner,
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 = NodePacket.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
290
- spawn = spawn_block(model: model, packet: build_result[:path], test_command: test_command, call_cap: calls_cap)
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
- packet: build_result[:path], spawn: spawn },
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 packet this
335
- # call's own `packet_builder` may have written is ever deleted.
336
- def rollback_dispatch(context, node:, kind:, packet_path:, runner:, worktree:, created_this_dispatch:)
337
- File.delete(packet_path) if packet_path && File.exist?(packet_path)
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, packet_failures: [],
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 packet build writes no ledger line at all,
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) + packet_failures.map { |f| packet_failure_blocker(f) }
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 packet_failure_blocker(failure)
468
- "#{failure[:node]}: packet build failed (#{Array(failure[:errors]).join('; ')})"
467
+ def input_failure_blocker(failure)
468
+ "#{failure[:node]}: node input build failed (#{Array(failure[:errors]).join('; ')})"
469
469
  end
470
- private_class_method :packet_failure_blocker
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 packet path, the model, the worktree, the kind and
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 packet. Row 6.4: "spawn" carries the same, already fully
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], "packet" => d[:packet] }
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
- # packets/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
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 NodePacket uses to name that attempt's packet file, so the
203
- # extensions file for a `running` line always matches the packet it extends.
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, "packets", "#{node}--a#{attempt}.extensions")
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 packet off that
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)
@@ -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, split out of bridge.rb). Everything here is derived from
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 bridge, 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. bridge.rb (the session
12
- # pointer, arm and disarm, lock repair) requires this file; this file never
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 a bridge. The input is always a file inside the intent
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 bridge or
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 Bridge/Worktree
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 Bridge.blank?(test_summary)
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 the pointer day into handoff--<session>.md before the tmp dir
21
- # goes. The hook script builds it with the shipped templates dir.
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
- pointer_day = safely { read_pointer_day(store, session) } || today
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
- checklist = SessionLedger.checklist_path(store, pointer_day)
53
- count = SessionLedger.flip_all(checklist, from: :pending, to: :dropped, session: session)
54
- if count.positive?
55
- SessionLedger.append_line(SessionLedger.savepoint_path(store, pointer_day),
56
- SessionLedger.savepoint_line("Note", session, project,
57
- "dropped #{count} pending lines at close", now: now))
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, since the pointer lives there.
64
- # An intent pointer falls back to today, the same as the drop above.
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, pointer_day, session)
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
- if pointer_day < today
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
- args = ["--day", pointer_day, "--carry-to", today, "--store", store]
81
- spawner.call(args)
82
- args
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 id named by the session's pointer file when `ticket_source`
189
- # is `intent_id`, else the day id (spec D4). Per intent 298 spec D6, the
190
- # pointer at `.tmp/<session>/current` holds exactly one line: either
191
- # today's day id (the session records into the day ledger) or an intent
192
- # id (an auto team owns the record). A day id in the pointer is therefore
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
- pointer = SessionLedger.pointer_path(store, session)
199
- return day.to_s unless File.exist?(pointer)
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
- content = File.read(pointer).to_s.strip
202
- return day.to_s if content.empty?
203
- return day.to_s if SessionLedger.valid_day_id?(content)
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
- content
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