@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. package/scripts/lib/bridge.rb +0 -116
@@ -19,12 +19,11 @@
19
19
  # corrupt lock with the resolving doctor hint
20
20
  # who compact, read-only durable owner/activity view
21
21
  # status report the lock file, its freshness, the derived worktree,
22
- # whether this session's pointer names the intent, and any
23
- # live per-artifact claims
22
+ # whether this session is the one delivering the intent, and
23
+ # any live per-artifact claims
24
24
  # fix idempotent repair of the lock for the current session; never
25
25
  # touches a fresh foreign lock
26
- # release owner clears the lock and points the session back at the day
27
- # ledger (End tail / abandoning a boarding)
26
+ # release owner clears the lock (End tail / abandoning a boarding)
28
27
  # reclaim explicit takeover of a stale lock, audited in savepoint.md
29
28
  # delegate owner registers a subagent session under the lock (D4)
30
29
  # claim acquire the named --artifact's claim token (intent 111);
@@ -32,18 +31,18 @@
32
31
  # including this same session; takes over a stale claim
33
32
  # release-claim free the named --artifact's claim token
34
33
  #
35
- # Without --intent-dir the intent is resolved from this session's pointer
36
- # (~/.plastic/store/.tmp/<session>/current) when it names an intent id, looked
37
- # up in the working directory's project store and then the global store. The
38
- # /tmp bridge JSON this CLI used to resolve through was removed in 2.0
39
- # (intent 307). Exit 0 on success/report; exit 1 when the verb is blocked.
34
+ # Without --intent-dir the intent is resolved from this session's held
35
+ # delivery lock (ActiveDelivery.resolve over the global store and the
36
+ # configured project roots): exactly one fresh lock this session owns or
37
+ # delegates for. Session resolution no longer reads any per-session state
38
+ # file; that path was retired in 2.0 (intent 307) and 344 G11.
39
+ # Exit 0 on success/report; exit 1 when the verb is blocked.
40
40
 
41
41
  require "json"
42
42
  require "yaml"
43
- require_relative "lib/bridge"
44
43
  require_relative "lib/lock"
45
44
  require_relative "lib/arm"
46
- require_relative "lib/session_ledger"
45
+ require_relative "lib/active_delivery"
47
46
 
48
47
  VERBS = %w[arm status who fix release reclaim delegate claim release-claim].freeze
49
48
 
@@ -88,16 +87,7 @@ hint_harness = harness || "claude"
88
87
  plastic_home_env = ENV["PLASTIC_HOME"].to_s.strip
89
88
  home = plastic_home_env.empty? ? Dir.home : File.dirname(File.expand_path(plastic_home_env))
90
89
 
91
- # The stores this session's pointer is resolved against: the project store of
92
- # the working directory (projects.yml, longest path match), then the global one.
93
- def candidate_stores(home, cwd)
94
- plastic_home = File.join(home, ".plastic")
95
- stores = []
96
- slug = SessionLedger.project_slug(cwd, plastic_home: plastic_home)
97
- stores << File.join(plastic_home, "projects", slug, "store") if slug && slug != "global"
98
- stores << File.join(plastic_home, "store")
99
- stores
100
- end
90
+ plastic_home = File.join(home, ".plastic")
101
91
 
102
92
  dir = opts[:dir]
103
93
  if dir.nil? || dir.strip.empty?
@@ -105,11 +95,21 @@ if dir.nil? || dir.strip.empty?
105
95
  warn "plastic-lock: #{verb} needs --intent-dir <intent dir>"
106
96
  exit 1
107
97
  end
108
- dir = Arm.intent_dir_from_pointer(session, home: home, stores: candidate_stores(home, Dir.pwd))
109
- end
110
- if dir.nil?
111
- warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
112
- exit 1
98
+ roots = ActiveDelivery.project_roots(plastic_home)
99
+ global_store = File.join(plastic_home, "store")
100
+ dir = ActiveDelivery.resolve(global_store: global_store, project_roots: roots, session: session)
101
+ if dir.nil?
102
+ candidates = ActiveDelivery.candidate_intent_dirs(global_store: global_store, project_roots: roots).uniq
103
+ held = candidates.select do |candidate|
104
+ Lock.fresh?(candidate) && Lock.holds?(candidate, session: session)
105
+ end
106
+ if held.size > 1
107
+ warn "plastic-lock: more than one held delivery lock names this session; pass --intent-dir <intent dir>"
108
+ else
109
+ warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
110
+ end
111
+ exit 1
112
+ end
113
113
  end
114
114
  dir = File.expand_path(dir)
115
115
 
@@ -144,7 +144,7 @@ if verb == "who"
144
144
  exit 0
145
145
  end
146
146
 
147
- intent_id = Bridge.intent_id_from_dir(dir)
147
+ intent_id = Arm.intent_id_for(dir)
148
148
  store = File.dirname(dir)
149
149
  key = Arm.resolve_session(session, store: store, intent_id: intent_id)
150
150
 
@@ -163,7 +163,7 @@ when "arm"
163
163
  puts JSON.pretty_generate(
164
164
  "intent_dir" => dir, "session" => result[:session], "status" => result[:status].to_s,
165
165
  "run_mode" => result[:lock] && result[:lock]["run_mode"],
166
- "worktree" => wt, "pointer" => result[:pointer]
166
+ "worktree" => wt
167
167
  )
168
168
  when :held
169
169
  warn "plastic-lock: delivery lock for intent #{intent_id} is held by session " \
@@ -188,7 +188,10 @@ when "arm"
188
188
  end
189
189
  when "status"
190
190
  lock = Lock.read(dir)
191
- pointer = Arm.read_pointer(key, home: home)
191
+ resolved = ActiveDelivery.resolve(global_store: File.join(plastic_home, "store"),
192
+ project_roots: ActiveDelivery.project_roots(plastic_home),
193
+ session: key)
194
+ delivering = !resolved.nil? && File.expand_path(resolved) == File.expand_path(dir)
192
195
  report = {
193
196
  "intent_dir" => dir,
194
197
  "session" => key,
@@ -196,8 +199,7 @@ when "status"
196
199
  "lock_fresh" => Lock.fresh?(dir),
197
200
  "lock_corrupt" => Lock.corrupt?(dir),
198
201
  "worktree" => Arm.worktree_block(intent_dir: dir, home: home),
199
- "pointer" => pointer,
200
- "pointer_names_intent" => pointer == intent_id,
202
+ "delivering" => delivering,
201
203
  "claims" => Claim.claims_status(dir),
202
204
  }
203
205
  puts JSON.pretty_generate(report)
@@ -217,7 +219,6 @@ when "release"
217
219
  warn "plastic-lock: not the owner; run plastic-lock status"
218
220
  exit 1
219
221
  end
220
- Arm.reset_pointer(key, intent_id, home: home)
221
222
  puts "released (#{result})"
222
223
  when "reclaim"
223
224
  status, lock_data = Lock.takeover(dir, session: key, harness: harness,
@@ -33,6 +33,12 @@ DEFAULTS = {
33
33
  },
34
34
  "architect" => {
35
35
  "style" => nil
36
+ },
37
+ # Intent 340b (G7c, n4, D9): the Stop hook's runtime arm. Off until the
38
+ # owner's C32 ruling; read-config emits it as the string "false", which
39
+ # StopGate parses as a boolean itself rather than trusting.
40
+ "runner" => {
41
+ "stop_hook" => false
36
42
  }
37
43
  }.freeze
38
44
 
package/scripts/runner CHANGED
@@ -2,24 +2,28 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
+ require "yaml"
6
+ require "stringio"
5
7
  require_relative "lib/savepoint"
6
8
  require_relative "lib/ready_set"
7
9
  require_relative "lib/runner_core"
10
+ require_relative "lib/harness_adapter"
8
11
  require_relative "lib/runner_proposals"
9
12
 
10
13
  # runner - the one executable over the graph-ready loop's declared node graph
11
14
  # (intent 340, G7, n1). A subcommand table: the public verbs (step, status,
12
15
  # answer) are the only ones the skill body ever names; the internal verbs
13
- # (ready, sweep, rewind) route and work so `step` can compose them and so
14
- # each can be tested alone, but stay out of the usage text (327 spec).
16
+ # (ready, sweep, rewind, until-empty) route and work so `step` can compose
17
+ # them and so each can be tested alone, but stay out of the usage text (327
18
+ # spec, 340b n7).
15
19
  #
16
- # `step`, `sweep`, `answer` and `rewind` route to modules later nodes
17
- # deliver (RunnerDispatch and RunnerAbsorb for `step`; RunnerSweep,
18
- # RunnerAnswer, RunnerRewind for the rest). Each is required LAZILY, inside
19
- # its own verb branch: a verb whose module has not landed yet reports
20
- # plainly and exits nonzero, and every other verb - most importantly
21
- # `status`, which an operator polls constantly across all five of this
22
- # intent's dispatches - keeps working.
20
+ # `step`, `sweep`, `answer`, `rewind` and `until-empty` route to modules
21
+ # later nodes deliver (RunnerDispatch and RunnerAbsorb for `step`;
22
+ # RunnerSweep, RunnerAnswer, RunnerRewind, RunnerUntilEmpty for the rest).
23
+ # Each is required LAZILY, inside its own verb branch: a verb whose module
24
+ # has not landed yet reports plainly and exits nonzero, and every other verb
25
+ # - most importantly `status`, which an operator polls constantly across all
26
+ # five of this intent's dispatches - keeps working.
23
27
  #
24
28
  # Usage:
25
29
  # runner <step|status|answer> <intent_dir> [--node ID] [--answer TEXT]
@@ -34,7 +38,7 @@ module Runner
34
38
  module_function
35
39
 
36
40
  PUBLIC_VERBS = %w[step status answer].freeze
37
- INTERNAL_VERBS = %w[ready sweep rewind].freeze
41
+ INTERNAL_VERBS = %w[ready sweep rewind until-empty watch].freeze
38
42
  VERBS = (PUBLIC_VERBS + INTERNAL_VERBS).freeze
39
43
 
40
44
  # verb -> [[ModuleName, lib_file], ...], required lazily inside the verb's
@@ -47,6 +51,8 @@ module Runner
47
51
  "sweep" => [["RunnerSweep", "runner_sweep"]],
48
52
  "answer" => [["RunnerAnswer", "runner_answer"]],
49
53
  "rewind" => [["RunnerRewind", "runner_rewind"]],
54
+ "until-empty" => [["RunnerUntilEmpty", "runner_until_empty"]],
55
+ "watch" => [["RunnerWatch", "runner_watch"]],
50
56
  }.freeze
51
57
 
52
58
  def usage
@@ -77,11 +83,21 @@ module Runner
77
83
  # a mistyped `--session` used to vanish with no effect and the runner
78
84
  # simply acted as whichever session `CLAUDE_CODE_SESSION_ID` names.
79
85
  KNOWN_FLAGS = {
80
- "step" => %w[--return --allow-core-drift],
86
+ "step" => %w[--return --allow-core-drift --harness],
81
87
  "answer" => %w[--node --answer],
82
88
  "rewind" => %w[--node --confirm],
89
+ "until-empty" => %w[--harness],
90
+ "watch" => %w[--dispatch --harness --install-timer --home],
83
91
  }.freeze
84
92
 
93
+ # The explicit YAML document-end marker (intent 340b, G7c, n1, row 1.28)
94
+ # separating the dispatch plan `step` has always printed from the
95
+ # harness-rendered block underneath it: stdout has never been one YAML
96
+ # document (absorb/reclaim/extend/reap lines print before the plan,
97
+ # decision/park lines after it), so a reader splits on this marker rather
98
+ # than guessing where the plan ends.
99
+ DOCUMENT_BOUNDARY = "...".freeze
100
+
85
101
  # unrecognized_flag(verb, args) -> the first token in `args` that looks
86
102
  # like a flag (`--...`) and is not in `verb`'s own KNOWN_FLAGS, or nil. A
87
103
  # verb absent from KNOWN_FLAGS (status, ready, sweep) accepts none.
@@ -203,6 +219,10 @@ module Runner
203
219
  run_answer(intent_dir, args)
204
220
  when "rewind"
205
221
  run_rewind(intent_dir, args)
222
+ when "until-empty"
223
+ run_until_empty(intent_dir, args)
224
+ when "watch"
225
+ run_watch(intent_dir, args)
206
226
  else
207
227
  warn "runner: #{verb}'s module loaded but no dispatcher is wired up yet"
208
228
  exit 3
@@ -284,14 +304,156 @@ module Runner
284
304
  exit 0
285
305
  end
286
306
 
287
- # run_step (intent 340, G7, n5): one turn of the loop, in the fixed order
288
- # 327 D14 and RunnerSweep's own docstring name - abort on a stuck merge,
289
- # the delivery-lease heartbeat, absorb every `--return NODE=PATH` this call
290
- # carries, THEN reclaim (so a node this very call just absorbed is never
291
- # reclaimed out from under its own return, matrix row 2.19/2.20) - and only
292
- # then dispatch whatever is left ready. Never spawns an agent (327 D42):
293
- # the session makes every subagent call from the plan this prints.
307
+ # run_until_empty (intent 340b, G7c, n7): the Codex loop. Internal, like
308
+ # `rewind` (row 7.13) - `RunnerUntilEmpty.run` composes `step` and
309
+ # `node-run` itself, since Codex has no session on the other end to make
310
+ # the subagent calls `step` only ever prints a plan for. `--harness`
311
+ # threads into every turn the loop runs (row 7.5), never re-resolved per
312
+ # iteration from a literal. The lock check here is the same defence in
313
+ # depth `run_step_body` carries (B4): `RunnerUntilEmpty.step_once` refuses
314
+ # `lock_not_held` on its own first call regardless, so this exits before
315
+ # even constructing the loop's own state.
316
+ def run_until_empty(intent_dir, args)
317
+ harness_override = opt(args, "--harness")
318
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
319
+
320
+ unless context.session
321
+ warn "runner: until-empty refused (lock_not_held)"
322
+ exit 1
323
+ end
324
+
325
+ result = RunnerUntilEmpty.run(context, harness: harness_override)
326
+
327
+ case result[:status]
328
+ when "complete", "stalled", "needs_decision", "iteration_cap"
329
+ exit 0
330
+ else
331
+ exit 1
332
+ end
333
+ end
334
+
335
+ # run_watch (intent 340a, G7b, n2): one tick over disk truth. Internal,
336
+ # like until-empty and rewind - the timer that carries it lives outside
337
+ # this process (a local `/loop` on Claude Code, a launchd job on Codex,
338
+ # n3/n4). `--dispatch` is unattended start (graph.md D7): refused here,
339
+ # before RunnerWatch.tick ever takes the tick lock or writes anything,
340
+ # when the resolved harness's `HarnessAdapter.unattended_start?` is
341
+ # false, so a refusal never leaves a `watch.record` line behind that
342
+ # looks like a completed tick.
343
+ # --install-timer (intent 340a, G7b, n3, graph.md D9): the Codex carrier.
344
+ # Writes the plist and prints the path plus the `launchctl load` command;
345
+ # never runs `launchctl` itself, so the owner stays in control of when the
346
+ # job actually loads. `--home` defaults to the OS home, as
347
+ # `scripts/meter-watch --install-timer` already does.
348
+ def run_watch(intent_dir, args)
349
+ dispatch = args.include?("--dispatch")
350
+ harness_override = opt(args, "--harness")
351
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
352
+ agent_config = load_agent_config(context.plastic_home)
353
+ harness = HarnessAdapter.resolve_key(config: agent_config, override: harness_override)
354
+
355
+ if args.include?("--install-timer")
356
+ home = opt(args, "--home") || Dir.home
357
+ plist_path = RunnerWatch.install_timer(context, home: home, harness_key: harness)
358
+ puts "plist: #{plist_path}"
359
+ puts "launchctl load #{plist_path}"
360
+ exit 0
361
+ end
362
+
363
+ if dispatch && !HarnessAdapter.unattended_start?(harness)
364
+ warn HarnessAdapter::UNATTENDED_START_SENTENCE
365
+ exit 1
366
+ end
367
+
368
+ result = RunnerWatch.tick(context, dispatch: dispatch, harness: harness)
369
+
370
+ puts "class: #{result[:class]}"
371
+ result[:blockers].each { |b| puts "blocked: #{b}" }
372
+ puts "ready: #{result[:ready].empty? ? '(none)' : result[:ready].join(', ')}"
373
+ puts "dispatched: #{result[:dispatched].empty? ? '(none)' : result[:dispatched].join(', ')}"
374
+ exit 0
375
+ end
376
+
377
+ # run_step (intent 340, G7, n5): one turn of the loop. The real work lives
378
+ # in #run_step_body below; this wrapper (intent 340b, G7c, n1, rows
379
+ # 1.21-1.25 and 1.29-1.31) exists ONLY to capture every byte `step` prints
380
+ # - stdout and stderr both, refusals included - and persist it to
381
+ # `<intent_dir>/runner-step.last` before the process actually exits, so a
382
+ # PreCompact hand-off (n3) always has something to render, even after one
383
+ # of the three pre-plan refusals that used to warn to stderr alone.
384
+ #
385
+ # `run_step_body` still calls Kernel#exit(N) at every one of its own exit
386
+ # points, exactly as before this node; wrapping it in a rescue for
387
+ # SystemExit lets this method capture the intended status, persist the
388
+ # file, replay the captured output onto the REAL stdout/stderr (so a
389
+ # subprocess caller like Open3.capture3 sees byte-identical output to
390
+ # before this node), and only then perform the one real process exit.
294
391
  def run_step(intent_dir, args)
392
+ out_buf = StringIO.new
393
+ err_buf = StringIO.new
394
+ real_stdout = $stdout
395
+ real_stderr = $stderr
396
+ exit_code = 0
397
+ begin
398
+ $stdout = out_buf
399
+ $stderr = err_buf
400
+ run_step_body(intent_dir, args)
401
+ rescue SystemExit => e
402
+ exit_code = e.status || 1
403
+ ensure
404
+ $stdout = real_stdout
405
+ $stderr = real_stderr
406
+ end
407
+
408
+ real_stdout.print(out_buf.string)
409
+ real_stderr.print(err_buf.string)
410
+ persist_runner_step_last(intent_dir, out_buf.string, err_buf.string)
411
+
412
+ exit(exit_code)
413
+ end
414
+
415
+ # Row 1.24: lives beside savepoint.md, never inside attempts/ (which the
416
+ # reaper treats as attempt evidence). Row 1.32: registers itself in the
417
+ # store's own top-level `.gitignore` (Worktree.ensure_gitignored's own
418
+ # idempotent, non-raising append) the same way `*.lock` already is, so a
419
+ # file rewritten on every step never dirties the store tree. Row 1.25:
420
+ # wrapped in its own rescue - an unwritable intent directory must never
421
+ # crash a step that has already written its transitions.
422
+ def persist_runner_step_last(intent_dir, stdout_text, stderr_text)
423
+ home_root = Arm.home_for(intent_dir)
424
+ Worktree.ensure_gitignored(File.join(home_root, ".plastic"), "runner-step.last")
425
+
426
+ path = File.join(intent_dir, "runner-step.last")
427
+ File.write(path, "#{stdout_text}#{stderr_text}")
428
+ rescue StandardError => e
429
+ warn "runner: could not persist runner-step.last: #{e.message}"
430
+ end
431
+
432
+ # Intent 340b, G7c, n1, row 1.1: the config `HarnessAdapter.resolve_key`
433
+ # reads `agent.type` from. `context.plastic_home` is derived from the
434
+ # intent_dir's OWN ancestry (Arm.home_for -> Worktree.home_from_store), so
435
+ # a sandboxed test fixture never reaches the real ~/.plastic/config.yml
436
+ # (Arm.home_for's own docstring: "a sandboxed store never resolves to the
437
+ # real Dir.home") - this stays hermetic for free, the same way every other
438
+ # `context.plastic_home` read in this file already does. A missing or
439
+ # unparseable config.yml reads as {}, never raises.
440
+ def load_agent_config(plastic_home)
441
+ path = File.join(plastic_home.to_s, "config.yml")
442
+ return {} unless File.exist?(path)
443
+
444
+ YAML.safe_load(File.read(path)) || {}
445
+ rescue StandardError
446
+ {}
447
+ end
448
+
449
+ # run_step_body (intent 340, G7, n5): one turn of the loop, in the fixed
450
+ # order 327 D14 and RunnerSweep's own docstring name - abort on a stuck
451
+ # merge, the delivery-lease heartbeat, absorb every `--return NODE=PATH`
452
+ # this call carries, THEN reclaim (so a node this very call just absorbed
453
+ # is never reclaimed out from under its own return, matrix row 2.19/2.20) -
454
+ # and only then dispatch whatever is left ready. Never spawns an agent (327
455
+ # D42): the session makes every subagent call from the plan this prints.
456
+ def run_step_body(intent_dir, args)
295
457
  context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
296
458
 
297
459
  # v1 minor 3/row 11.18: RunnerSweep.abort_if_merging already prints the
@@ -322,6 +484,12 @@ module Runner
322
484
  # never defaulted true.
323
485
  allow_core_drift = args.include?("--allow-core-drift")
324
486
 
487
+ # Intent 340b, G7c, n1, row 1.2/1.4: `--harness KEY` overrides config
488
+ # `agent.type` for this one call - how one machine drives both adapters
489
+ # without editing config. `nil` when absent, so HarnessAdapter falls
490
+ # through to config's own value untouched.
491
+ harness_override = opt(args, "--harness")
492
+
325
493
  returns = {}
326
494
  opt_all(args, "--return").each do |pair|
327
495
  node, path = pair.to_s.split("=", 2)
@@ -351,7 +519,8 @@ module Runner
351
519
  reaped = NodeWorktree.reap(context)
352
520
  reaped[:removed].each { |r| puts "reaped #{r[:node]} worktree" }
353
521
 
354
- result = RunnerDispatch.dispatch(context)
522
+ agent_config = load_agent_config(context.plastic_home)
523
+ result = RunnerDispatch.dispatch(context, config: agent_config, harness: harness_override)
355
524
  unless result[:ok]
356
525
  warn "runner: step refused (#{result[:reason]}): #{Array(result[:errors]).join('; ')}"
357
526
  warn result[:rearm_command] if result[:rearm_command]
@@ -374,6 +543,21 @@ module Runner
374
543
  result[:blockers].each { |b| puts "blocked: #{b}" }
375
544
  end
376
545
 
546
+ # Intent 340b, G7c, n1, rows 1.6/1.13/1.14/1.27/1.28: the YAML plan keeps
547
+ # printing exactly as it did before this node (327's contract) - this is
548
+ # the rendered block UNDERNEATH it, for the harness `result[:harness]`
549
+ # resolved (config's `agent.type`, or this call's own `--harness`
550
+ # override), separated by an explicit YAML document-end marker so a
551
+ # reader splits on that marker rather than a heuristic. `render` returns
552
+ # nil for an empty dispatch list (row 1.14), so nothing extra ever
553
+ # prints when this step dispatched nothing.
554
+ block = HarnessAdapter.render(result[:dispatched], harness: result[:harness],
555
+ return_contract: RunnerDispatch::RETURN_CONTRACT)
556
+ if block
557
+ puts DOCUMENT_BOUNDARY
558
+ puts block
559
+ end
560
+
377
561
  # M11/row 10.14: a `needs_decision` stop must print even when THIS SAME
378
562
  # step also dispatched other nodes ahead of the decision node in ranked
379
563
  # order - `result[:status]` reads "dispatched" in that case (dispatched
@@ -11,7 +11,7 @@
11
11
  #
12
12
  # --base overrides the auto-detected diff base (default: the merge base with the repo's
13
13
  # detected default branch) for repos where detection is ambiguous. --suite is an optional
14
- # caller-supplied command to run (in the repo directory) and fold into the verdict. No
14
+ # caller-supplied command to run (in the repo directory) and merge into the verdict. No
15
15
  # other flags exist.
16
16
  #
17
17
  # Checks, all run every invocation (the fourth only when --suite is given):
@@ -46,10 +46,10 @@ QMD-first (when available): when the user describes the work instead of naming a
46
46
  ## Take the intent (do this FIRST)
47
47
 
48
48
  Immediately after selecting the intent, take it for this session. One verb acquires the durable
49
- `delivery.lock` in the intent directory (stamped `run_mode: auto`), provisions the code worktree
50
- at `<repo>/.claude/worktrees/{id}--{slug}` on branch `plastic/{id}--{slug}`, and points this
51
- session at the intent (`~/.plastic/store/.tmp/<session>/current`), so the record hook writes
52
- savepoint lines and heartbeats for it instead of the day ledger:
49
+ `delivery.lock` in the intent directory (stamped `run_mode: auto`) and provisions the code worktree
50
+ at `<repo>/.claude/worktrees/{id}--{slug}` on branch `plastic/{id}--{slug}`; the delivery lock
51
+ names this session as the one delivering the intent, so the record hook writes savepoint lines
52
+ and heartbeats for it instead of the day ledger:
53
53
 
54
54
  ```bash
55
55
  codex="${CODEX_THREAD_ID:-}"; claude="${CLAUDE_CODE_SESSION_ID:-}"
@@ -78,7 +78,7 @@ for work small enough to skip speccing, delivered inline with no separate plan r
78
78
  is no intent tier and no stage agent; depth follows the work.
79
79
 
80
80
  `runner step` computes readiness and prints a spawn block per dispatched node - agent, model,
81
- packet path, the test command, the call cap - fenced for a session to paste into the Agent
81
+ node input path, the test command, the call cap - fenced for a session to paste into the Agent
82
82
  tool; the runner never spawns (327 D42). `runner status` renders the ledger; `runner answer`
83
83
  closes a `needs_decision` node.
84
84
 
@@ -165,7 +165,7 @@ ledger is missing (then rebuild it with `Savepoint.rebuild_savepoint`).
165
165
  | `How plan.md created` / `How checklist.md created` / `Exec started` | Exec (verify plan, matrix, checklist) |
166
166
  | `Exec outcome.md created` | Exec done; complete the intent |
167
167
  | A terminal savepoint line (`delivered` or `abandoned`) | Terminal; do not resume |
168
- | A node or `Intent` transition line (`n1 running ...`, `Intent needs_decision ...`) | Exec; a graph delivery is in progress - drive it through `scripts/runner`'s three public verbs, `step` (one turn of the dispatch loop), `status` (renders ledger state, safe to poll constantly), and `answer` (closes a `needs_decision` node) - read node status through `NodeLedger.status` before dispatching anything, never re-derive it by eye |
168
+ | A node or `Intent` transition line (`n1 running ...`, `Intent needs_decision ...`) | Exec; a graph delivery is in progress - drive it through `scripts/runner`'s three public verbs, `step` (one turn of the dispatch loop), `status` (renders ledger state, safe to poll constantly), and `answer` (closes a `needs_decision` node) - read node status through `NodeLedger.status` before dispatching anything, never re-derive it by eye. `scripts/graph-measure` is the read-only sibling over that same ledger, with its own three public verbs `intent`, `budget`, and `cohorts` - it never dispatches and never writes |
169
169
 
170
170
  Filesystem fallback, in order: `checklist.md` with items checked means resume Exec from the
171
171
  first unchecked item; `plan.md` plus `checklist.md` means enter Exec; `spec.md` alone means
@@ -259,8 +259,8 @@ Read `../plastic-conventions/references/completion-and-done.md` for what "intent
259
259
  4. Review `## Insights` for observations that should become future intents; create them through
260
260
  `plastic-intent-creating` and update `chain`.
261
261
  5. Close through `plastic-intent-ending`, which runs `scripts/end-intent`: outcome, INDEX,
262
- savepoint, the store commit, and the disarm (the worktree released, `delivery.lock` cleared,
263
- the session pointer back on the day ledger), then the QMD reindex last and the single owner
262
+ savepoint, the store commit, and the disarm (the worktree released, `delivery.lock`
263
+ cleared), then the QMD reindex last and the single owner
264
264
  report. Pass `--session` and `--index-note`:
265
265
  ```bash
266
266
  ruby ~/.plastic/scripts/end-intent --store <store_path> --id <ID> --disposition delivered \
@@ -2,8 +2,8 @@
2
2
 
3
3
  Deep mechanics behind two spots in `SKILL.md`: how `plastic-lock arm` resolves a session id
4
4
  when it takes an intent, and why the End-tail steps in Completion (release the worktree, clear
5
- the lock, reset the pointer, reindex) run in that exact order. The `/tmp` bridge JSON these
6
- steps once also purged was removed in 2.0 (intent 307); `scripts/lib/arm.rb` is what remains.
5
+ the lock, reindex) run in that exact order. `scripts/lib/arm.rb` carries the arm, disarm, and
6
+ repair operations.
7
7
 
8
8
  ## Table of Contents
9
9
 
@@ -22,18 +22,16 @@ session-less close resolve to the same key.
22
22
  Arming acquires the durable `delivery.lock` in the intent dir, keyed by that resolved session
23
23
  and stamped with `run_mode`. Ownership is session-keyed, not process-keyed, so the arm command
24
24
  exiting is fine by construction: the lock stays yours for every later tool call in this
25
- session, and the record hook refreshes its lease on every write you make. The same call writes
26
- the intent id into the session pointer (`~/.plastic/store/.tmp/<session>/current`), which is
25
+ session, and the record hook refreshes its lease on every write you make. Holding that lock is
27
26
  why the capture and record hooks stop treating your prompts as day-ledger items for the rest
28
27
  of the delivery.
29
28
 
30
29
  ## Disarm ordering and worktree cleanup rationale
31
30
 
32
31
  `Arm.disarm` runs the ordered End tail: it releases the worktree first, then clears the
33
- intent's `delivery.lock` as its recorded owner, and only then resets the session pointer to
34
- today's day id (when it still names this intent). The worktree goes first so a released lock
35
- never points at a checkout another session could claim; the pointer goes last so the record
36
- hook keeps heartbeating the lock until the lock is gone.
32
+ intent's `delivery.lock` as its recorded owner. The worktree goes first so a released lock
33
+ never points at a checkout another session could claim; the lock goes last so the record
34
+ hook keeps heartbeating it until the delivery is actually done.
37
35
 
38
36
  **Mechanized since intent 188.** `scripts/end-intent` performs this disarm itself, as its own
39
37
  step 5, after steps 1-4 (outcome/INDEX/savepoint/commit) commit. A pre-flight lock guard
@@ -52,10 +50,10 @@ survives and can be reclaimed).
52
50
  When the work is being shipped through a release, do NOT rely on this plain remove.
53
51
  `skills/releasing/SKILL.md` runs its worktree-merge step BEFORE its `end-intent` call, merging
54
52
  the intent's code branch (`plastic/{id}--{slug}`) back to the repo's default branch BEFORE
55
- the worktree is removed, via `Worktree.finish(Arm.bridge_hash(intent_dir: ...), merge: true)`
53
+ the worktree is removed, via `Worktree.finish(Arm.delivery(intent_dir: ...), merge: true)`
56
54
  (merge-then-remove), so the integrated work is not lost. By the time `end-intent`'s own step 5
57
- runs afterward, the worktree is already gone (a harmless no-op) and only the delivery lock and
58
- the pointer are left to clear. Never leave an orphaned worktree, and run `git worktree prune`
55
+ runs afterward, the worktree is already gone (a harmless no-op) and only the delivery lock is
56
+ left to clear. Never leave an orphaned worktree, and run `git worktree prune`
59
57
  if you hit a stale reference.
60
58
 
61
59
  ## QMD reindex ordering rationale
@@ -17,10 +17,10 @@ self-declares its disposition through a `disposition: delivered|abandoned` front
17
17
  header. The delivered path authors it with the result; the abandoned path authors it with
18
18
  the abandonment reason and no longer leaves the scaffolded placeholder sentinel in place.
19
19
 
20
- The canonical End tail runs in this order, and the QMD reindex is always LAST, after the
21
- purge: `outcome.md -> INDEX terminal -> the terminal savepoint line -> commit -> disarm
22
- (Worktree.release -> Lock.release -> purge) -> QMD reindex`. Running the reindex last keeps the index from
23
- ever referencing a bridge or lock that disarm is about to remove.
20
+ The canonical End tail runs in this order: `outcome.md -> INDEX terminal -> the terminal
21
+ savepoint line -> commit -> disarm (Worktree.release -> Lock.release) -> QMD reindex`, the
22
+ reindex always LAST. Running the reindex last keeps the index from ever referencing a lock
23
+ that disarm just removed.
24
24
 
25
25
  `scripts/end-intent` performs this order's disarm step (verify the code worktree is clean,
26
26
  then merge/remove worktrees, then clear the lock) as its own step 5, mechanically, since
@@ -31,12 +31,11 @@ foreign session, reclaims a stale one with an audit line), and a dirty code work
31
31
  refuses before removal rather than force-discarding uncommitted changes.
32
32
 
33
33
  The post-done access window is lock-bounded: `[INDEX terminal -> Lock.release]`. Through it
34
- the completing session keeps full read and write access to the terminal directory and no
35
- purge can fire (108's lock-held keep-guard keeps the bridge while `delivery.lock` exists).
36
- Once the lock is released the window closes: the bridge becomes purge-eligible and the
37
- directory is frozen. A crash mid-tail is recovered by stale-lock reclaim plus finishing the
38
- tail; `doctor` surfaces this as a "stalled completion" (terminal in INDEX but the lock is
39
- still present or stale). Finishing the tail is FINISHING a completion, never a reactivation:
34
+ the completing session keeps full read and write access to the terminal directory (108's
35
+ lock-held keep-guard holds it open while `delivery.lock` exists). Once the lock is released
36
+ the window closes and the directory is frozen. A crash mid-tail is recovered by stale-lock
37
+ reclaim plus finishing the tail; `doctor` surfaces this as a "stalled completion" (terminal in
38
+ INDEX but the lock is still present or stale). Finishing the tail is FINISHING a completion, never a reactivation:
40
39
  a done intent is never moved back to `## Active`.
41
40
 
42
41
  One report per audience: a delivery produces `outcome.md` plus one EM-to-CTO owner report, and
@@ -45,3 +45,12 @@ This chapter holds the linking doctrine from Frontmatter and the branch-vs-root
45
45
  the relation on the PREDECESSOR's `chain` (and mirror it as a
46
46
  `[[id--slug|<target's full intent: text>]]` wikilink in `## Links`).
47
47
  - **Rule of thumb:** if the intent could exist without its parent, it's a root.
48
+
49
+ ## Naming
50
+
51
+ A thing is named after the concept family it lives under. A node is a graph-engineering
52
+ concept, so its name comes from graph engineering (node, edge, ready set, critical path),
53
+ from the Plastic concepts coined on top of it (intent, ledger, node input, lease, gate, runner),
54
+ and from the software and AI engineering concepts those rest on (review, fix, test, verify,
55
+ dispatch, executor, reviewer). A name from outside that stack is refused. Where no existing
56
+ concept fits, that is a design finding to raise, not a word to coin.