@zalom/plastic 2.0.0-alpha.21 → 2.0.0-alpha.23

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 (86) hide show
  1. package/PLASTIC.md +20 -18
  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/doctor.rb +159 -10
  13. package/scripts/end-intent +3 -3
  14. package/scripts/graph-measure +249 -0
  15. package/scripts/hook-capture +1 -0
  16. package/scripts/hook-savepoint +24 -2
  17. package/scripts/hook-session-start +333 -321
  18. package/scripts/hook-stop +57 -0
  19. package/scripts/insight-append +18 -4
  20. package/scripts/lib/active_delivery.rb +61 -0
  21. package/scripts/lib/agent_models.rb +10 -1
  22. package/scripts/lib/codex_adapter.rb +197 -0
  23. package/scripts/lib/doctor_core.rb +8 -3
  24. package/scripts/lib/engine_permissions.rb +88 -0
  25. package/scripts/lib/graph_edges.rb +4 -4
  26. package/scripts/lib/graph_file.rb +4 -4
  27. package/scripts/lib/graph_measure.rb +645 -0
  28. package/scripts/lib/graph_measure_budget.rb +408 -0
  29. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  30. package/scripts/lib/graph_measure_models.rb +411 -0
  31. package/scripts/lib/graph_measure_report.rb +532 -0
  32. package/scripts/lib/graph_tree.rb +2 -2
  33. package/scripts/lib/handoff.rb +36 -5
  34. package/scripts/lib/harness_adapter.rb +184 -0
  35. package/scripts/lib/hook_registry.rb +13 -1
  36. package/scripts/lib/hook_replay.rb +23 -5
  37. package/scripts/lib/index_projection.rb +1 -1
  38. package/scripts/lib/installer_core.rb +112 -6
  39. package/scripts/lib/intent_screen.rb +1 -1
  40. package/scripts/lib/intent_validator.rb +2 -2
  41. package/scripts/lib/meter_watch.rb +15 -9
  42. package/scripts/lib/node_file.rb +3 -3
  43. package/scripts/lib/node_ledger.rb +8 -1
  44. package/scripts/lib/node_progress.rb +153 -0
  45. package/scripts/lib/outcome_report.rb +1 -1
  46. package/scripts/lib/report_screen.rb +10 -6
  47. package/scripts/lib/roadmap_graph.rb +1 -1
  48. package/scripts/lib/roadmap_queue.rb +1 -1
  49. package/scripts/lib/roadmap_render.rb +1 -1
  50. package/scripts/lib/runner_absorb.rb +31 -5
  51. package/scripts/lib/runner_dispatch.rb +26 -11
  52. package/scripts/lib/runner_until_empty.rb +252 -0
  53. package/scripts/lib/runner_watch.rb +389 -0
  54. package/scripts/lib/savepoint.rb +3 -3
  55. package/scripts/lib/session_git.rb +2 -2
  56. package/scripts/lib/stop_gate.rb +95 -0
  57. package/scripts/lib/verify_intent.rb +2 -2
  58. package/scripts/lib/work_graph_validator.rb +6 -6
  59. package/scripts/new-intent +1 -1
  60. package/scripts/node-run +224 -0
  61. package/scripts/read-config +6 -0
  62. package/scripts/runner +203 -19
  63. package/scripts/skill-lint +115 -6
  64. package/scripts/verify-intent +1 -1
  65. package/skills/auto/SKILL.md +54 -56
  66. package/skills/auto/references/agent-architecture.md +10 -8
  67. package/skills/auto/references/human-report-contract.md +1 -1
  68. package/skills/conventions/references/completion-and-done.md +7 -7
  69. package/skills/conventions/references/knowledge-graph.md +9 -0
  70. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  71. package/skills/conventions/references/maintenance-and-revisions.md +1 -1
  72. package/skills/doctor/SKILL.md +3 -3
  73. package/skills/doctor/report.md +1 -1
  74. package/skills/intent-continuing/references/boarding-matrix.md +2 -2
  75. package/skills/intent-creating/SKILL.md +58 -133
  76. package/skills/intent-creating/evals/evals.json +1 -1
  77. package/skills/intent-ending/SKILL.md +48 -56
  78. package/skills/intent-ending/evals/evals.json +1 -1
  79. package/skills/intent-executing/SKILL.md +43 -136
  80. package/skills/intent-speccing/SKILL.md +3 -0
  81. package/skills/releasing/SKILL.md +1 -1
  82. package/skills/releasing/references/release-lines.md +1 -1
  83. package/skills/tutorial/SKILL.md +2 -1
  84. package/skills/tutorial/references/track-1-guided.md +21 -40
  85. package/skills/tutorial/references/track-2-auto.md +3 -3
  86. package/templates/agents.md +2 -2
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 packets/ (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
@@ -2,11 +2,13 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # skill-lint: deterministic CLI over SkillLint (intent 85b).
5
+ # skill-lint: deterministic CLI over SkillLint (intent 85b), plus one CLI-local
6
+ # check (intent 341, G8, n1, C35).
6
7
  #
7
- # Runs the five structural skill checks (body-budget, frontmatter-validity,
8
- # bare-pointer, orphan-files, references-depth) over a directory of Agent
9
- # Skills and reports every violation. Mirrors `scripts/validate-intent`'s
8
+ # Runs the five structural checks (body-budget, frontmatter-validity,
9
+ # bare-pointer, orphan-files, references-depth) via SkillLint, then this
10
+ # script's own refusal-restatement check, over a directory of Agent Skills,
11
+ # and reports every violation. Mirrors `scripts/validate-intent`'s
10
12
  # CLI-over-lib shape and exit-code contract.
11
13
  #
12
14
  # Usage:
@@ -17,6 +19,112 @@
17
19
 
18
20
  require_relative "lib/skill_lint"
19
21
 
22
+ # RefusalRestatementCheck (C35): a skill body must not restate, past a short
23
+ # run of words, a refusal rule the conventions chapter already carries; it
24
+ # must link that chapter instead. No-op when <skills_dir>/conventions/references
25
+ # does not exist, so an older tree or a fixture dir with no conventions chapter
26
+ # is unaffected.
27
+ #
28
+ # Deliberately lives in this CLI, not in scripts/lib/skill_lint.rb: C35 is a
29
+ # doctrine-duplication check, one level removed from SkillLint's five
30
+ # structural checks, and keeping it here means adding it touches only the
31
+ # files this change is scoped to.
32
+ class RefusalRestatementCheck
33
+ REFUSAL_KEYWORD_RE = /\brefus(e|es|ed|ing|al)\b/i
34
+ CONVENTIONS_LINK_RE = %r{conventions/references/|plastic-conventions}i
35
+
36
+ # How many consecutive normalized words must match, verbatim, between a
37
+ # skill's refusal paragraph and the doctrine text before it counts as a
38
+ # restatement rather than a coincidental shared phrase.
39
+ NGRAM = 6
40
+
41
+ def initialize(skills_dir)
42
+ @skills_dir = skills_dir
43
+ end
44
+
45
+ def run
46
+ doctrine = doctrine_blob
47
+ return [] unless doctrine
48
+
49
+ skill_md_paths.flat_map do |skill_md|
50
+ skill_dir = File.dirname(skill_md)
51
+ next [] if File.basename(skill_dir) == "conventions" # the source never restates itself
52
+
53
+ check_skill(skill_dir, skill_md, File.read(skill_md), doctrine)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def skill_md_paths
60
+ Dir.glob(File.join(@skills_dir, "*", "SKILL.md")).sort
61
+ end
62
+
63
+ def doctrine_blob
64
+ chapters = Dir.glob(File.join(@skills_dir, "conventions", "references", "*.md")).sort
65
+ return nil if chapters.empty?
66
+
67
+ text = chapters.map { |f| File.read(f) }.join(" ")
68
+ " #{normalize_words(text).join(" ")} "
69
+ end
70
+
71
+ def normalize_words(text)
72
+ text.downcase.gsub("`", "").gsub(/[^a-z0-9\s-]/, " ").split(/\s+/).reject(&:empty?)
73
+ end
74
+
75
+ # Same split as SkillLint's own frontmatter/body divide: `content.split("---", 3)`.
76
+ def body_and_offset(content)
77
+ parts = content.split("---", 3)
78
+ return [content, 0] if parts.length < 3
79
+
80
+ prefix_len = parts[0].length + 3 + parts[1].length + 3
81
+ [parts[2], content[0...prefix_len].count("\n")]
82
+ end
83
+
84
+ # Blank-line-delimited paragraph blocks, so a refusal keyword and its
85
+ # doctrine echo are compared across the whole paragraph, not one line.
86
+ def paragraph_blocks(lines)
87
+ blocks = []
88
+ start = nil
89
+ lines.each_with_index do |line, i|
90
+ if line.strip.empty?
91
+ blocks << { start: start, text: lines[start..(i - 1)].join } if start
92
+ start = nil
93
+ else
94
+ start ||= i
95
+ end
96
+ end
97
+ blocks << { start: start, text: lines[start..].join } if start
98
+ blocks
99
+ end
100
+
101
+ def restated?(block_text, doctrine)
102
+ words = normalize_words(block_text)
103
+ return false if words.length < NGRAM
104
+
105
+ (0..(words.length - NGRAM)).any? do |i|
106
+ doctrine.include?(" #{words[i, NGRAM].join(" ")} ")
107
+ end
108
+ end
109
+
110
+ def check_skill(skill_dir, skill_md, content, doctrine)
111
+ name = File.basename(skill_dir)
112
+ body, offset = body_and_offset(content)
113
+
114
+ paragraph_blocks(body.lines).filter_map do |block|
115
+ next unless block[:text].match?(REFUSAL_KEYWORD_RE)
116
+ next if block[:text].match?(CONVENTIONS_LINK_RE) # links the chapter instead of restating
117
+ next unless restated?(block[:text], doctrine)
118
+
119
+ {
120
+ check: "refusal-restatement", skill: name, file: skill_md, line: offset + block[:start] + 1,
121
+ message: "this paragraph restates a refusal rule word-for-word from " \
122
+ "skills/conventions/references/; link the chapter instead of repeating its text",
123
+ }
124
+ end
125
+ end
126
+ end
127
+
20
128
  def resolve_skills_dir(args)
21
129
  if (i = args.index("--skills-dir"))
22
130
  args[i + 1]
@@ -37,14 +145,15 @@ end
37
145
 
38
146
  dir = File.expand_path(resolve_skills_dir(ARGV))
39
147
  result = SkillLint.new(skills_dir: dir).run
148
+ violations = result.violations + RefusalRestatementCheck.new(dir).run
40
149
 
41
- if result.ok?
150
+ if violations.empty?
42
151
  puts "OK: #{dir}"
43
152
  exit 0
44
153
  end
45
154
 
46
155
  warn "VIOLATIONS: #{dir}"
47
- result.violations.each do |v|
156
+ violations.each do |v|
48
157
  warn "#{v[:check]} #{v[:skill]} #{v[:file]}:#{v[:line].nil? ? "-" : v[:line]} #{v[:message]}"
49
158
  end
50
159
  exit 1
@@ -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):
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: plastic-auto
3
3
  description: >-
4
- Autonomous intent delivery - a background team takes a registered intent from How to Done.
4
+ Autonomous intent delivery - a background team takes a registered intent from How to End.
5
5
  Use when user says "auto", "take it from here", "deliver this", or when a thinking
6
6
  conversation concludes and the user confirms autonomous execution. Requires an active intent
7
7
  in INDEX.md.
@@ -71,35 +71,29 @@ owner override. Do not proceed as the owner after an exit 1.
71
71
  Read `../plastic-conventions/references/locks-and-worktrees.md` for what the lock and the
72
72
  worktree mean and the station table behind them. Code edits happen only inside the worktree.
73
73
 
74
- ## The shape (five steps, two agent boots)
74
+ ## The shape
75
75
 
76
- Every auto delivery runs the same shape, ruled by the owner on 2026-08-29. There is no intent
77
- tier and no stage agent; depth follows the work.
76
+ Work is a graph (`graph.md`: Goal, Decisions, Graph, Status; one `nodes/*.md` per node) or,
77
+ for work small enough to skip speccing, delivered inline with no separate plan review. There
78
+ is no intent tier and no stage agent; depth follows the work.
78
79
 
79
- | Step | Who | What lands |
80
- |---|---|---|
81
- | 1. The lead writes How | this session | `plan.md`, at least one `actions/ACTION_N.md` carrying a failure-mode matrix (one row per operation: the failure and the test that catches it), `checklist.md` |
82
- | 2. Adversarial plan review | boot 1, a fresh agent on `plan-reviewer-prompt.md` | a review file; the lead folds every finding into the spec, the matrix, and the tests |
83
- | 3. Execute, tests first | boot 2, `plastic-executor` | the red commit (the matrix's tests, failing), then the code, then a green suite |
84
- | 4. Review by risk | boot 3 only when risk calls for it (below) | a pass or a list of fixes the executor applies |
85
- | 5. One suite run, then close | this session | `outcome.md`, `end-intent`, the roadmap ledger |
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
82
+ tool; the runner never spawns (327 D42). `runner status` renders the ledger; `runner answer`
83
+ closes a `needs_decision` node.
86
84
 
87
- Two boots is the normal delivery; the third is the exception the risk rule names. The lead is
88
- this session (the `plastic-enforcer` role), never a dispatched agent.
89
-
90
- A lead is a choice, not a requirement (D8, 355). `runner step` computes the plan and prints a
91
- spawn block per node - agent, model, packet path, the test command, the call cap - fenced for a
92
- session to paste into the Agent tool; the runner never spawns (327 D42). A lead earns its keep
93
- on a graph carrying a decision node, weighing its `needs_decision` stop; a graph with none runs
94
- end to end from `runner step` alone.
85
+ A lead is a choice, not a requirement (D8, 355). A lead earns its keep on a graph carrying a
86
+ decision node, weighing its `needs_decision` stop; a graph with none runs end to end from
87
+ `runner step` alone. When this session leads, it is the `plastic-enforcer` role, never a
88
+ dispatched agent.
95
89
 
96
90
  ## Team
97
91
 
98
- - **plastic-enforcer**: this session. Writes the Why and How record, dispatches, folds reviews,
99
- verifies, closes.
92
+ - **plastic-enforcer**: this session. Writes the Why and How record, dispatches, applies
93
+ review findings, verifies, closes.
100
94
  - **plastic-executor**: one dispatch per intent, implements the consolidated action tests first,
101
95
  ticks the checklist, appends `## Insights`, drives the suite green.
102
- - **the plan reviewer**: one dispatch before code, from `plastic-intent-executing`'s
96
+ - **the plan reviewer**: an optional dispatch before code, from `plastic-intent-executing`'s
103
97
  `plan-reviewer-prompt.md`; a fresh agent, never the lead.
104
98
  - **the post-execution reviewer**: dispatched only by the risk rule, from
105
99
  `code-quality-reviewer-prompt.md`; a fresh agent, never the maker.
@@ -154,7 +148,9 @@ Only the owner can delegate. Delegates cannot re-delegate or release.
154
148
  Headless note: in a headless or background run the session id may be unset; the arm verb then keys the lock by a derived key and the record hook still writes the ledger.
155
149
  Verify with `plastic-lock status` rather than assuming.
156
150
 
157
- Solo fallback: on a harness with no agent dispatch (Codex CLI today), this session walks the five steps itself, still writing the matrix and the tests first and reviewing its own plan against the matrix before code, saying so in `## Insights`.
151
+ Solo fallback: on a harness with no agent dispatch (Codex CLI today), this session walks the
152
+ graph (or the plan) itself, still writing the matrix and the tests first and reviewing its own
153
+ plan against the matrix before code, saying so in `## Insights`.
158
154
 
159
155
  ## Stage-Aware Entry
160
156
 
@@ -168,8 +164,8 @@ ledger is missing (then rebuild it with `Savepoint.rebuild_savepoint`).
168
164
  | `Why spec.md created` | How |
169
165
  | `How plan.md created` / `How checklist.md created` / `Exec started` | Exec (verify plan, matrix, checklist) |
170
166
  | `Exec outcome.md created` | Exec done; complete the intent |
171
- | `Done delivered|abandoned` | Terminal; do not resume |
172
- | 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 |
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. `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 |
173
169
 
174
170
  Filesystem fallback, in order: `checklist.md` with items checked means resume Exec from the
175
171
  first unchecked item; `plan.md` plus `checklist.md` means enter Exec; `spec.md` alone means
@@ -179,42 +175,43 @@ Announce which stage you are entering and why.
179
175
 
180
176
  ## Why (the lead)
181
177
 
182
- 1. Read `## Context` and `### Decisions`; assess the gaps.
183
- 2. Research yourself: code, docs, related intents through `## Links`, the web if needed. No
184
- questions to the human.
185
- 3. Decide: pick the best option per gap, record it in `## Context > ### Decisions` with the
186
- rationale, and log it in `## Insights` with the `(autonomous)` marker through
187
- `scripts/insight-append`.
188
- 4. Write `spec.md`. Then How.
178
+ For a graph delivery, Why is already written into `graph.md`'s Goal and Decisions; nothing
179
+ else to do here. For work with no graph: read `## Context` and `### Decisions`, assess the
180
+ gaps, research them yourself (code, docs, related intents through `## Links`, the web if
181
+ needed; no questions to the human), record each decision in `## Context > ### Decisions` with
182
+ its rationale, log it in `## Insights` with the `(autonomous)` marker through
183
+ `scripts/insight-append`, and write `spec.md` only when the intent needs one (speccing is
184
+ optional). Then How.
189
185
 
190
186
  ## How (the lead), then the plan review
191
187
 
192
- 1. Write `plan.md`: numbered steps.
193
- 2. Write at least one real `actions/ACTION_N.md` (one consolidated `ACTION_1.md` by default;
194
- several only when the work splits into independent, parallel-safe actions). Each action
195
- carries the failure-mode matrix: one row per operation, the failure mode, and the test that
196
- catches it. A `.gitkeep`-only `actions/` is not a finished How.
197
- 3. Write `checklist.md` covering every action.
198
- 4. Dispatch the plan reviewer (boot 1) with `plastic-intent-executing`'s
199
- `plan-reviewer-prompt.md`, the spawn preamble, and the intent directory. Fold every finding
200
- into the spec, the matrix, and the tests; record what was dropped and why in the action
201
- file's review fold. A REVISE verdict is folded and not re-reviewed unless a finding changes
202
- a decision.
203
- 5. Print `ruby ~/.plastic/scripts/report-screen plan <intent_dir>` as the first characters of
204
- the reply, nothing before it, no fence, before dispatching the executor (see
205
- `references/human-report-contract.md` for the full binding table). It informs; it does not wait.
188
+ For a graph delivery, How is `graph.md` itself: no `plan.md`, no separate plan review (D1,
189
+ 341). For work with no graph: write `plan.md` (numbered steps) and at least one real
190
+ `actions/ACTION_N.md` carrying the failure-mode matrix (one row per operation, the failure
191
+ mode, the test that catches it; a `.gitkeep`-only `actions/` is not a finished How), then
192
+ `checklist.md` covering every action. The plan reviewer is optional, not a required step:
193
+ when the delivery warrants review before code, dispatch it (boot 1) with
194
+ `plastic-intent-executing`'s `plan-reviewer-prompt.md`, the spawn preamble, and the intent
195
+ directory, apply every finding to the spec, the matrix, and the tests, and record what was
196
+ dropped and why in the action file's review notes. A REVISE verdict is applied and not
197
+ re-reviewed unless a finding changes a decision.
198
+
199
+ Print `ruby ~/.plastic/scripts/report-screen plan <intent_dir>` as the first characters of
200
+ the reply, nothing before it, no fence, before dispatching the executor (see
201
+ `references/human-report-contract.md` for the full binding table). It informs; it does not wait.
206
202
 
207
203
  Then Exec.
208
204
 
209
205
  ## Exec (the executor)
210
206
 
211
- 1. Dispatch `plastic-executor` (boot 2) through `plastic-intent-executing` with the whole
212
- consolidated action pasted in: the spec decisions, the matrix, the checklist items, the
213
- worktree path from the preamble. Tests first: the executor commits the matrix's tests red,
214
- then builds, then drives the full suite green.
215
- 2. Read its return by code: DONE or DONE_WITH_CONCERNS proceeds; NEEDS_CONTEXT re-dispatches
216
- with the missing context; BLOCKED stops under the error procedure.
217
- 3. Tick the checklist as items land (the executor does this); verify tick-versus-diff against the diff. A mismatch is a review finding, not a lead cleanup.
207
+ For a graph delivery, `runner step` prints the spawn block for the next ready node; paste it
208
+ into the Agent tool, verbatim. For work with no graph, dispatch `plastic-executor` (boot 2)
209
+ through `plastic-intent-executing` with the whole consolidated action pasted in.
210
+
211
+ 1. Read the executor's return by code: DONE or DONE_WITH_CONCERNS proceeds; NEEDS_CONTEXT
212
+ re-dispatches with the missing context; BLOCKED stops under the error procedure.
213
+ 2. Tick the checklist as items land (the executor does this); verify tick-versus-diff against
214
+ the diff. A mismatch is a review finding, not a lead cleanup.
218
215
 
219
216
  ## Review by risk (boot 3, only when a rule fires)
220
217
 
@@ -224,13 +221,14 @@ holds, each checkable from disk with no judgment; otherwise the green suite is t
224
221
  1. `git diff --name-only <red-commit>..HEAD` touches a path on the risk list in
225
222
  `references/agent-architecture.md` (hooks, the lock, the arming module, the installer, a
226
223
  release file).
227
- 2. A row of any `actions/ACTION_N.md` failure-mode matrix names a test file that is not in that
228
- diff, or a test the green run did not execute.
224
+ 2. A row of any failure-mode matrix (an `actions/ACTION_N.md` or a `nodes/*.md` file) names a
225
+ test file that is not in that diff, or a test the green run did not execute.
229
226
  3. The executor's completion report carries a status other than `delivered`, or a non-empty
230
227
  `deviations` or `blockers` field.
231
228
 
232
229
  The reviewer returns a pass or a list of fixes; the executor (re-dispatched) applies them, then
233
- the suite runs once more.
230
+ the suite runs once more. On a graph, the risk rule maps onto the verify nodes named in
231
+ `graph.md`'s decisions; at most one review-fix round, never more.
234
232
  ## Project Creation
235
233
 
236
234
  If the plan calls for creating a new project, determine the path from `~/.plastic/config.yml`