@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.
- package/PLASTIC.md +20 -18
- package/agents/plastic-enforcer.md +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +159 -10
- package/scripts/end-intent +3 -3
- package/scripts/graph-measure +249 -0
- package/scripts/hook-capture +1 -0
- package/scripts/hook-savepoint +24 -2
- package/scripts/hook-session-start +333 -321
- package/scripts/hook-stop +57 -0
- package/scripts/insight-append +18 -4
- package/scripts/lib/active_delivery.rb +61 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +408 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +36 -5
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/installer_core.rb +112 -6
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/node_ledger.rb +8 -1
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/runner_absorb.rb +31 -5
- package/scripts/lib/runner_dispatch.rb +26 -11
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +3 -3
- package/scripts/lib/session_git.rb +2 -2
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/new-intent +1 -1
- package/scripts/node-run +224 -0
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/skill-lint +115 -6
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +54 -56
- package/skills/auto/references/agent-architecture.md +10 -8
- package/skills/auto/references/human-report-contract.md +1 -1
- package/skills/conventions/references/completion-and-done.md +7 -7
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +3 -3
- package/skills/conventions/references/maintenance-and-revisions.md +1 -1
- package/skills/doctor/SKILL.md +3 -3
- package/skills/doctor/report.md +1 -1
- package/skills/intent-continuing/references/boarding-matrix.md +2 -2
- package/skills/intent-creating/SKILL.md +58 -133
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-ending/SKILL.md +48 -56
- package/skills/intent-ending/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +43 -136
- package/skills/intent-speccing/SKILL.md +3 -0
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/SKILL.md +2 -1
- package/skills/tutorial/references/track-1-guided.md +21 -40
- package/skills/tutorial/references/track-2-auto.md +3 -3
- package/templates/agents.md +2 -2
|
@@ -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
|
|
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
|
|
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 (
|
|
12
|
-
# ProjectValidator returns (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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)
|
package/scripts/new-intent
CHANGED
|
@@ -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
|
-
#
|
|
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|
|
package/scripts/node-run
ADDED
|
@@ -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__
|
package/scripts/read-config
CHANGED
|
@@ -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
|
|