@zalom/plastic 2.0.0-alpha.22 → 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 (67) hide show
  1. package/agents/plastic-enforcer.md +6 -3
  2. package/agents/plastic-executor.md +4 -0
  3. package/agents/plastic-node-research.md +28 -0
  4. package/agents/plastic-node-verify.md +27 -0
  5. package/agents/plastic-node-work.md +32 -0
  6. package/bin/lib/context_budget.rb +1 -1
  7. package/hooks/hooks.json +12 -0
  8. package/hooks/statusline +28 -0
  9. package/hooks/stop +5 -0
  10. package/package.json +1 -1
  11. package/scripts/doctor.rb +80 -6
  12. package/scripts/end-intent +3 -3
  13. package/scripts/graph-measure +249 -0
  14. package/scripts/hook-capture +1 -0
  15. package/scripts/hook-savepoint +24 -2
  16. package/scripts/hook-session-start +40 -0
  17. package/scripts/hook-stop +57 -0
  18. package/scripts/lib/active_delivery.rb +61 -0
  19. package/scripts/lib/agent_models.rb +10 -1
  20. package/scripts/lib/codex_adapter.rb +197 -0
  21. package/scripts/lib/doctor_core.rb +8 -3
  22. package/scripts/lib/engine_permissions.rb +88 -0
  23. package/scripts/lib/graph_edges.rb +4 -4
  24. package/scripts/lib/graph_file.rb +4 -4
  25. package/scripts/lib/graph_measure.rb +645 -0
  26. package/scripts/lib/graph_measure_budget.rb +408 -0
  27. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  28. package/scripts/lib/graph_measure_models.rb +411 -0
  29. package/scripts/lib/graph_measure_report.rb +532 -0
  30. package/scripts/lib/graph_tree.rb +2 -2
  31. package/scripts/lib/handoff.rb +36 -5
  32. package/scripts/lib/harness_adapter.rb +184 -0
  33. package/scripts/lib/hook_registry.rb +13 -1
  34. package/scripts/lib/hook_replay.rb +23 -5
  35. package/scripts/lib/index_projection.rb +1 -1
  36. package/scripts/lib/installer_core.rb +109 -3
  37. package/scripts/lib/intent_screen.rb +1 -1
  38. package/scripts/lib/intent_validator.rb +2 -2
  39. package/scripts/lib/meter_watch.rb +15 -9
  40. package/scripts/lib/node_file.rb +3 -3
  41. package/scripts/lib/node_ledger.rb +8 -1
  42. package/scripts/lib/node_progress.rb +153 -0
  43. package/scripts/lib/outcome_report.rb +1 -1
  44. package/scripts/lib/report_screen.rb +10 -6
  45. package/scripts/lib/roadmap_graph.rb +1 -1
  46. package/scripts/lib/roadmap_queue.rb +1 -1
  47. package/scripts/lib/roadmap_render.rb +1 -1
  48. package/scripts/lib/runner_absorb.rb +31 -5
  49. package/scripts/lib/runner_dispatch.rb +26 -11
  50. package/scripts/lib/runner_until_empty.rb +252 -0
  51. package/scripts/lib/runner_watch.rb +389 -0
  52. package/scripts/lib/savepoint.rb +3 -3
  53. package/scripts/lib/session_git.rb +2 -2
  54. package/scripts/lib/stop_gate.rb +95 -0
  55. package/scripts/lib/verify_intent.rb +2 -2
  56. package/scripts/lib/work_graph_validator.rb +6 -6
  57. package/scripts/new-intent +1 -1
  58. package/scripts/node-run +224 -0
  59. package/scripts/read-config +6 -0
  60. package/scripts/runner +203 -19
  61. package/scripts/verify-intent +1 -1
  62. package/skills/auto/SKILL.md +1 -1
  63. package/skills/conventions/references/knowledge-graph.md +9 -0
  64. package/skills/doctor/SKILL.md +3 -3
  65. package/skills/intent-creating/evals/evals.json +1 -1
  66. package/skills/intent-executing/SKILL.md +6 -0
  67. package/skills/tutorial/references/track-2-auto.md +1 -1
@@ -0,0 +1,95 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "active_delivery"
6
+ require_relative "lock"
7
+ require_relative "ready_set"
8
+
9
+ # StopGate (intent 340b, G7c, n4, D5/D7/D8/D9): may this session stop? Blocks
10
+ # only when four conditions hold together: the payload's stop_hook_active is
11
+ # true, runner.stop_hook parses as a real boolean true, this session holds
12
+ # the delivering intent's delivery.lock live and in auto mode, and the last
13
+ # runner step left work a new turn can actually move (some node is ready to
14
+ # dispatch right now - a graph whose only non-terminal node is already
15
+ # running, or that has no ready node at all, permits, because blocking that
16
+ # would trip Claude Code's own eight-block ceiling for nothing).
17
+ #
18
+ # Fail-open is the design, not a fallback: a missing store, a torn lock, an
19
+ # unreadable graph, a raised exception, a payload that is not JSON - every
20
+ # one of those permits. #decide never raises.
21
+ module StopGate
22
+ module_function
23
+
24
+ # decide(payload:, config_stop_hook:, session:, global_store:, project_roots:, ttl:, now:) ->
25
+ # {block: false} or {block: true, reason: "..."}.
26
+ #
27
+ # `payload` is the hook input: already a Hash, or the raw JSON text (row
28
+ # 4.19 - a payload that is not JSON permits, never raises). `config_stop_hook`
29
+ # is the raw string read-config returns for runner.stop_hook: parsed as a
30
+ # boolean here, never trusted as one.
31
+ def decide(payload:, config_stop_hook:, session:, global_store:, project_roots:,
32
+ ttl: Lock::TTL_SECONDS, now: Time.now, caps: nil)
33
+ payload = parse_payload(payload)
34
+
35
+ return permit unless payload["stop_hook_active"] == true
36
+ return permit unless flag_true?(config_stop_hook)
37
+
38
+ intent_dir = ActiveDelivery.resolve(global_store: global_store, project_roots: project_roots,
39
+ session: session, ttl: ttl, now: now)
40
+ return permit unless intent_dir
41
+
42
+ lock = Lock.read(intent_dir)
43
+ return permit unless lock
44
+ return permit unless lock["run_mode"].to_s == "auto"
45
+
46
+ return permit unless work_movable?(intent_dir, caps: caps)
47
+
48
+ block(intent_dir)
49
+ rescue StandardError
50
+ permit
51
+ end
52
+
53
+ # Accepts a Hash as-is, parses a String as JSON, and falls back to an
54
+ # empty Hash for anything else or anything that fails to parse - a
55
+ # malformed payload must never raise (row 4.19).
56
+ def parse_payload(payload)
57
+ return payload if payload.is_a?(Hash)
58
+ return {} unless payload.is_a?(String)
59
+
60
+ parsed = JSON.parse(payload)
61
+ parsed.is_a?(Hash) ? parsed : {}
62
+ rescue JSON::ParserError
63
+ {}
64
+ end
65
+
66
+ # A config value counts as on only when it parses as the literal boolean
67
+ # true (row 4.4): read-config emits strings, and the string "false" is
68
+ # truthy in Ruby, so testing the raw value would arm the hook on its own
69
+ # shipped default. Anything else, including an absent key (row 4.5), is off.
70
+ def flag_true?(value)
71
+ value.to_s.strip.downcase == "true"
72
+ end
73
+
74
+ # Some node is ready to dispatch right now. Never raises across this
75
+ # boundary (ReadySet.analyze already never does); an unreadable or absent
76
+ # graph, or an intent with no declared nodes, reads as ok: false or an
77
+ # empty ready set either way, and both permit.
78
+ def work_movable?(intent_dir, caps:)
79
+ analysis = caps ? ReadySet.analyze(intent_dir, caps: caps) : ReadySet.analyze(intent_dir)
80
+ analysis[:ok] && analysis[:ranked_ready].any?
81
+ end
82
+
83
+ def permit
84
+ { block: false }
85
+ end
86
+
87
+ # Names the next instruction (row 4.17): a session that is blocked and
88
+ # told nothing stops again immediately.
89
+ def block(intent_dir)
90
+ intent_id = File.basename(intent_dir.to_s)
91
+ reason = "Plastic: intent #{intent_id} still has ready work. Run `runner step` for it " \
92
+ "and dispatch what it prints before stopping."
93
+ { block: true, reason: reason }
94
+ end
95
+ end
@@ -9,7 +9,7 @@ require_relative "scaffold_intent"
9
9
  # (`Doctor#run_intent_check`), a net-new added-line em-dash diff guard (the first standing
10
10
  # implementation of this check; the only prior automated em-dash check,
11
11
  # `test/skill_command_lint_test.rb`, asserts against two FIXED file sets and cannot scan a
12
- # diff), a diffstat, and an optional caller-supplied `--suite` command folded into the same
12
+ # diff), a diffstat, and an optional caller-supplied `--suite` command merged into the same
13
13
  # verdict.
14
14
  #
15
15
  # Repo resolution and base-branch detection are NOT re-implemented here: `resolve_repo_dir`,
@@ -245,7 +245,7 @@ module VerifyIntent
245
245
 
246
246
  # Every `Report`-kind savepoint line for this intent, oldest first: [timestamp, text].
247
247
  # Never fails or gates anything (D3: the diffstat check already prints a summary block,
248
- # this folds into the same verdict so verify-intent surfaces them too) - a delivery with
248
+ # this merges into the same verdict so verify-intent surfaces them too) - a delivery with
249
249
  # no Report line is visible to `doctor`'s intent_reports_printed_check instead.
250
250
  def report_lines(intent_dir)
251
251
  path = File.join(intent_dir, "savepoint.md")
@@ -8,14 +8,14 @@ require_relative "action_graph_shim"
8
8
 
9
9
  # WorkGraphValidator (intent 334, n4): the in-batch reader over one intent's
10
10
  # graph.md and nodes/ - 327's rule for budget, files, and the decision and
11
- # research kinds (fold A17). {ok:, missing:, errors:}, the same Result shape
12
- # ProjectValidator returns (fold B7). Named apart from
11
+ # research kinds (review A17). {ok:, missing:, errors:}, the same Result shape
12
+ # ProjectValidator returns (review B7). Named apart from
13
13
  # IntentValidator#validate_graph, which already exists for the KNOWLEDGE
14
- # graph and stays unrelated to this one under D40 (fold B9).
14
+ # graph and stays unrelated to this one under D40 (review B9).
15
15
  #
16
16
  # Built over GraphFile, GraphEdges, and NodeFile only - never IntentValidator,
17
17
  # never ReportScreen. Every check accumulates into `errors` rather than
18
- # short-circuiting on the first one (fold: "the owner fixes one thing per
18
+ # short-circuiting on the first one (see: "the owner fixes one thing per
19
19
  # run").
20
20
  module WorkGraphValidator
21
21
  module_function
@@ -140,7 +140,7 @@ module WorkGraphValidator
140
140
 
141
141
  # A node "touches" the graph when some edge involves it on either side:
142
142
  # it targets something, or something targets it. A verify node declared
143
- # with "needs nothing" and targeted by nothing is fully isolated (fold
143
+ # with "needs nothing" and targeted by nothing is fully isolated (review
144
144
  # A13).
145
145
  def node_touches_graph?(id, edges)
146
146
  (edges[id] || []).any? || edges.values.any? { |targets| targets.include?(id) }
@@ -159,7 +159,7 @@ module WorkGraphValidator
159
159
  end
160
160
 
161
161
  # The matrix table must sit under a heading whose tokens include the
162
- # node's own id, and that heading must own at least one data row (fold
162
+ # node's own id, and that heading must own at least one data row (review
163
163
  # A1 - the same table-owning rule report_screen's resolver uses, so a
164
164
  # node's own Proven-by cell is never "not recorded" the moment it ships).
165
165
  def has_valid_matrix?(id, body)
@@ -362,7 +362,7 @@ def main(argv)
362
362
  # 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
363
363
  # `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
364
364
  # `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
365
- # folds in `--parent`). Collect the touched source files so their `## Links` can be
365
+ # includes `--parent`). Collect the touched source files so their `## Links` can be
366
366
  # re-projected once the chain edges are on disk.
367
367
  source_files = []
368
368
  sources.each do |src_id|
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ require "digest"
6
+ require "time"
7
+ require "yaml"
8
+ require "fileutils"
9
+ require_relative "lib/savepoint"
10
+ require_relative "lib/node_ledger"
11
+ require_relative "lib/node_packet"
12
+ require_relative "lib/node_worktree"
13
+ require_relative "lib/node_file"
14
+ require_relative "lib/ready_set"
15
+ require_relative "lib/runner_core"
16
+ require_relative "lib/codex_adapter"
17
+
18
+ # node-run - the Codex leg of one node's whole attempt (intent 340b, G7c, n6).
19
+ #
20
+ # On Claude Code a subagent dispatch IS the executor; on Codex the executor
21
+ # is a subprocess, so this script owns the timing of a whole node: it reads
22
+ # the node's live `running` line, resolves the packet that line names,
23
+ # builds the `codex exec` argv for the node's kind (CodexAdapter), runs it
24
+ # in the node's worktree with the packet on stdin, reads the executor's
25
+ # final message, and writes it to the attempt's own return path.
26
+ #
27
+ # It writes NO ledger transition, on any path - success, refusal or error
28
+ # alike. That is the whole point: exactly one thing writes `done` (the
29
+ # runner's own six-check absorb gate), and a second writer here would throw
30
+ # that guarantee away. The caller absorbs with `runner step --return
31
+ # ID=PATH` through the same six checks every other return goes through.
32
+ #
33
+ # Usage:
34
+ # node-run <intent_dir> --node <id> [--session SID] [--now ISO8601]
35
+ # [--timeout-seconds N]
36
+ #
37
+ # Exit codes: 0 - a return file was written (whatever its content actually
38
+ # says; that judgment belongs to the absorb gate, not this script). Every
39
+ # other code is a REFUSAL: nothing is written, node-run never even reached
40
+ # `codex exec`.
41
+ # 2 - usage: not an intent directory, or missing --node
42
+ # 3 - the node has no live `running` line
43
+ # 4 - the running line is held by another session
44
+ # 5 - the packet the running line names is missing from disk
45
+ # 6 - the packet on disk does not hash to the running line's packet=
46
+ # 7 - no worktree could be resolved to run in
47
+ module NodeRunCLI
48
+ module_function
49
+
50
+ def usage
51
+ warn "Usage: node-run <intent_dir> --node <id> [--session SID] [--now ISO8601] " \
52
+ "[--timeout-seconds N]"
53
+ end
54
+
55
+ def opt(args, name)
56
+ (i = args.index(name)) && args[i + 1]
57
+ end
58
+
59
+ def intent_directory?(dir)
60
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
61
+ end
62
+
63
+ # <intent_dir>/packets/<node>--a<N>.return.yml (spec): beside the packet,
64
+ # one per attempt, the same treatment packet files already get - never
65
+ # reused across attempts, so a second attempt's return can never be
66
+ # mistaken for the first's.
67
+ def return_path(intent_dir, node, attempt)
68
+ File.join(intent_dir, "packets", "#{node}--a#{attempt}.return.yml")
69
+ end
70
+
71
+ # The node's own declared kind, read straight off its node file - "work"
72
+ # when the file cannot be found or parsed at all (mirroring RunnerPolicy's
73
+ # own unknown-kind fallback), but the LITERAL string it declares
74
+ # otherwise, even an invalid one (matrix row 6.10): CodexAdapter's own
75
+ # kind table only distinguishes "read-only" (verify, research) from
76
+ # everything else, so an unrecognized kind still gets the widest, safest
77
+ # treatment rather than no treatment at all.
78
+ def resolve_kind(intent_dir, node)
79
+ path = ReadySet.find_node_path(intent_dir, node)
80
+ return "work" unless path
81
+
82
+ parsed = NodeFile.parse(path)
83
+ kind = parsed[:kind].to_s.strip
84
+ kind.empty? ? "work" : kind
85
+ end
86
+
87
+ # The directory `codex exec -C` runs in: a `work` node's own worktree when
88
+ # one is actually provisioned on disk, else the shared intent worktree -
89
+ # verify and research nodes never get a worktree of their own (327 D6,
90
+ # D29), so the only place they could ever leave a diff is the intent
91
+ # worktree itself, exactly where NodeWorktree.changed_paths already looks
92
+ # for one.
93
+ def node_target_dir(context, node:, kind:)
94
+ if NodeWorktree::WORKTREE_KINDS.include?(kind.to_s)
95
+ p = NodeWorktree.paths(context, node: node)
96
+ return p["path"] if p["path"] && Dir.exist?(p["path"])
97
+ end
98
+ context.worktree
99
+ end
100
+
101
+ # A synthetic, deliberately non-YAML-mapping line the absorb gate reads as
102
+ # unparsable (spec Approach: "a return file the gate reads as
103
+ # unparsable"), covering every subprocess failure the same way: a nonzero
104
+ # exit, a timeout, an empty message file, and a message with no YAML
105
+ # document either way. Carries no colon-shaped `key: value` text on
106
+ # purpose, so it can never accidentally parse as a YAML mapping that
107
+ # happens to satisfy NodeReturn's own schema.
108
+ def unparsable_body(node, result)
109
+ reason =
110
+ if result[:timed_out]
111
+ "node-run - codex exec for #{node} timed out with no usable return"
112
+ elsif !result[:exit_code].nil? && result[:exit_code] != 0
113
+ "node-run - codex exec for #{node} exited #{result[:exit_code]} with no usable return"
114
+ else
115
+ "node-run - codex exec for #{node} produced no parsable YAML document on either " \
116
+ "--output-last-message or stdout"
117
+ end
118
+ "#{reason}\n"
119
+ end
120
+
121
+ def main(argv)
122
+ args = argv.dup
123
+ intent_dir_arg = args.shift
124
+
125
+ unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
126
+ warn "node-run: #{intent_dir_arg.inspect} is not an intent directory"
127
+ usage
128
+ exit 2
129
+ end
130
+ intent_dir = File.expand_path(intent_dir_arg)
131
+
132
+ node = opt(args, "--node")
133
+ if node.to_s.strip.empty?
134
+ warn "node-run: --node is required"
135
+ usage
136
+ exit 2
137
+ end
138
+
139
+ session_flag = opt(args, "--session")
140
+ now_flag = opt(args, "--now")
141
+ timeout_flag = opt(args, "--timeout-seconds")
142
+
143
+ begin
144
+ now_flag ? Time.iso8601(now_flag) : Time.now
145
+ rescue ArgumentError
146
+ warn "node-run: --now must be ISO 8601"
147
+ exit 2
148
+ end
149
+
150
+ context = RunnerCore.context(intent_dir: intent_dir, session: session_flag,
151
+ env: ENV["CLAUDE_CODE_SESSION_ID"])
152
+
153
+ savepoint_path = File.join(intent_dir, "savepoint.md")
154
+ current_status = NodeLedger.status_for(savepoint_path, node)
155
+ running_entry = NodeLedger.last_running(savepoint_path, node)
156
+ unless current_status == "running" && running_entry
157
+ warn "node-run: #{node} has no live running line (state is #{current_status})"
158
+ exit 3
159
+ end
160
+
161
+ fields = running_entry[:fields] || {}
162
+ holder = fields["holder"]
163
+ if context.session.nil? || context.session.to_s != holder.to_s
164
+ warn "node-run: #{node}'s running line is held by #{holder.inspect}, not this session"
165
+ exit 4
166
+ end
167
+
168
+ kind = resolve_kind(intent_dir, node)
169
+
170
+ # matrix row 6.11: the attempt number comes from NodePacket's own
171
+ # counter, never a hand count. `lease_flag_given: false` because the
172
+ # running line this attempt is acting on ALREADY exists on disk (it is
173
+ # what we just read above) - counting it a second time would double it
174
+ # (the same count, plus one, that produced it in the first place when
175
+ # the packet was originally built).
176
+ attempt = NodePacket.compute_attempt_number(intent_dir: intent_dir, node: node, lease_flag_given: false)
177
+ packet_path = NodePacket.packet_path(intent_dir: intent_dir, node: node, attempt: attempt)
178
+
179
+ unless File.exist?(packet_path)
180
+ warn "node-run: #{node}'s packet is missing at #{packet_path}"
181
+ exit 5
182
+ end
183
+
184
+ packet_bytes = File.binread(packet_path)
185
+ actual_sha = Digest::SHA256.hexdigest(packet_bytes)[0, 12]
186
+ expected_sha = fields["packet"]
187
+ unless expected_sha && actual_sha == expected_sha
188
+ warn "node-run: #{node}'s packet at #{packet_path} does not hash to packet=#{expected_sha.inspect} " \
189
+ "(computed #{actual_sha})"
190
+ exit 6
191
+ end
192
+
193
+ worktree = node_target_dir(context, node: node, kind: kind)
194
+ if worktree.nil? || !Dir.exist?(worktree)
195
+ warn "node-run: #{node} has no resolvable worktree to run in"
196
+ exit 7
197
+ end
198
+
199
+ out_path = return_path(intent_dir, node, attempt)
200
+ FileUtils.mkdir_p(File.dirname(out_path))
201
+ message_path = "#{out_path}.msg"
202
+
203
+ timeout_seconds = timeout_flag ? timeout_flag.to_i : CodexAdapter.timeout_seconds(kind)
204
+ codex_argv = CodexAdapter.build_argv(kind: kind, worktree: worktree, output_last_message: message_path)
205
+
206
+ result = CodexAdapter.execute(codex_argv, stdin_data: packet_bytes, timeout_seconds: timeout_seconds,
207
+ output_last_message_path: message_path)
208
+
209
+ # matrix rows 6.20-6.22: a nonzero exit or a timeout is authoritative -
210
+ # any message text the child managed to leave behind is never trusted
211
+ # once either has happened. Only a clean exit with no usable message at
212
+ # all falls to "no parsable YAML document".
213
+ failed = result[:timed_out] || (!result[:exit_code].nil? && result[:exit_code] != 0)
214
+ body = failed ? unparsable_body(node, result) : (result[:message] || unparsable_body(node, result))
215
+
216
+ File.write(out_path, body)
217
+ File.delete(message_path) if File.exist?(message_path)
218
+
219
+ puts out_path
220
+ exit 0
221
+ end
222
+ end
223
+
224
+ NodeRunCLI.main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -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