@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.
- package/PLASTIC.md +6 -5
- 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/dashboard.rb +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +22 -19
- package/scripts/exec-worktree +5 -5
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +16 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +26 -3
- package/scripts/hook-session-start +49 -10
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- 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 +409 -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 +40 -13
- 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_entry.rb +53 -0
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +228 -9
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +8 -9
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +11 -2
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +2 -2
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.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/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +36 -10
- package/scripts/lib/runner_dispatch.rb +66 -51
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +9 -10
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +22 -16
- package/scripts/lib/session_ledger.rb +44 -4
- 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/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/new-intent +1 -1
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +224 -0
- package/scripts/plastic-lock +33 -32
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +8 -8
- package/skills/auto/references/end-tail.md +9 -11
- package/skills/conventions/references/completion-and-done.md +9 -10
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +4 -4
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +7 -1
- package/skills/releasing/SKILL.md +2 -2
- package/skills/releasing/references/promotion-and-tagging.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
|
@@ -154,14 +154,54 @@ module SessionLedger
|
|
|
154
154
|
File.join(tmp_root(store), session_id)
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
def pointer_path(store, session_id)
|
|
158
|
-
File.join(session_tmp_dir(store, session_id), "current")
|
|
159
|
-
end
|
|
160
|
-
|
|
161
157
|
def heartbeat_path(store, session_id)
|
|
162
158
|
File.join(session_tmp_dir(store, session_id), "heartbeat")
|
|
163
159
|
end
|
|
164
160
|
|
|
161
|
+
# --- Session day (spec D7 replacement for the retired pointer) ------------
|
|
162
|
+
|
|
163
|
+
# Every day, ascending, no later than `today` and no earlier than `today`
|
|
164
|
+
# minus `window_days`, whose checklist carries at least one line tagged to
|
|
165
|
+
# `session` (any state). Each day's checklist is read inside its own
|
|
166
|
+
# rescue, so one unreadable or missing checklist skips that day only and
|
|
167
|
+
# never gives up on a later one. Returns an empty array when no day in the
|
|
168
|
+
# window carries the session's line, and on any error reading the store
|
|
169
|
+
# (never raises).
|
|
170
|
+
def session_days(store, session, today:, window_days: 7)
|
|
171
|
+
root = sessions_root(store)
|
|
172
|
+
return [] unless File.directory?(root)
|
|
173
|
+
|
|
174
|
+
today_date = Date.strptime(today, "%Y%m%d")
|
|
175
|
+
floor_date = today_date - window_days
|
|
176
|
+
|
|
177
|
+
days = Dir.children(root).select { |name| valid_day_id?(name) }.select do |name|
|
|
178
|
+
date = Date.strptime(name, "%Y%m%d")
|
|
179
|
+
date >= floor_date && date <= today_date
|
|
180
|
+
end.sort
|
|
181
|
+
|
|
182
|
+
days.select do |day|
|
|
183
|
+
begin
|
|
184
|
+
read_locked(checklist_path(store, day)).each_line.any? do |line|
|
|
185
|
+
parsed = parse_checklist_line(line)
|
|
186
|
+
parsed && parsed[:session] == session
|
|
187
|
+
end
|
|
188
|
+
rescue StandardError
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
rescue StandardError
|
|
193
|
+
[]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The newest day in `session_days`, the day this session's close and
|
|
197
|
+
# hand-off write belong to (intent 344, G11, D13: a session that crosses
|
|
198
|
+
# midnight carries lines on more than one day, and the newest is the one
|
|
199
|
+
# still open). Falls back to `today` when no day in the window carries the
|
|
200
|
+
# session's line.
|
|
201
|
+
def session_day(store, session, today:, window_days: 7)
|
|
202
|
+
session_days(store, session, today: today, window_days: window_days).last || today
|
|
203
|
+
end
|
|
204
|
+
|
|
165
205
|
# Create `.tmp/` plus a `.gitignore` holding exactly `*`, if that file does
|
|
166
206
|
# not already exist. The store is a local git repo that auto-commits
|
|
167
207
|
# `add -A`, so without this ignore file every heartbeat write would enter
|
|
@@ -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/lib/worktree.rb
CHANGED
|
@@ -108,16 +108,16 @@ module Worktree
|
|
|
108
108
|
|
|
109
109
|
# --- provisioning ----------------------------------------------------------
|
|
110
110
|
|
|
111
|
-
# Resolve the slug from the
|
|
111
|
+
# Resolve the slug from the delivery's intent.store, create code + store
|
|
112
112
|
# worktrees (idempotent: reuse an existing worktree path, do not error), write
|
|
113
|
-
# the `worktree` block plus `provisioned: true` onto
|
|
113
|
+
# the `worktree` block plus `provisioned: true` onto delivery, return it.
|
|
114
114
|
#
|
|
115
115
|
# Fails open with a stderr log when the repo is non-git or unresolvable:
|
|
116
116
|
# sets `provisioned: false` and leaves `code: null`. All git ops use
|
|
117
117
|
# `git -C <resolved path>` -- never cwd (decision D6).
|
|
118
|
-
def provision(
|
|
119
|
-
return
|
|
120
|
-
intent =
|
|
118
|
+
def provision(delivery, home: Dir.home, runner: ShellRunner.new)
|
|
119
|
+
return delivery unless delivery.is_a?(Hash)
|
|
120
|
+
intent = delivery["intent"] || {}
|
|
121
121
|
intent_id = intent["id"].to_s
|
|
122
122
|
store = intent["store"].to_s
|
|
123
123
|
intent_slug = slug_from_dir(intent["dir"]) || slug_from_dir(store)
|
|
@@ -173,15 +173,15 @@ module Worktree
|
|
|
173
173
|
# replaced by the fresh unprovisioned block; the guard must not fail harder
|
|
174
174
|
# than the bug it guards.
|
|
175
175
|
if code_ok
|
|
176
|
-
|
|
176
|
+
delivery["worktree"] = block
|
|
177
177
|
else
|
|
178
|
-
existing =
|
|
178
|
+
existing = delivery["worktree"]
|
|
179
179
|
keep = existing.is_a?(Hash) && !blank?(existing["code"]) &&
|
|
180
180
|
Dir.exist?(existing["code"].to_s)
|
|
181
|
-
|
|
181
|
+
delivery["worktree"] = block unless keep
|
|
182
182
|
end
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
delivery
|
|
185
185
|
end
|
|
186
186
|
|
|
187
187
|
# Remove the worktree (then `git worktree prune`), clear the worktree block.
|
|
@@ -189,21 +189,21 @@ module Worktree
|
|
|
189
189
|
# policy on top via `finish`; this is the plain remove. Pass `remove: false` to
|
|
190
190
|
# clear the block WITHOUT touching git (so `finish` can merge first, then call
|
|
191
191
|
# release to drop the worktree once the code branch is integrated).
|
|
192
|
-
def release(
|
|
193
|
-
return
|
|
194
|
-
block =
|
|
195
|
-
return
|
|
192
|
+
def release(delivery, home: Dir.home, runner: ShellRunner.new, remove: true)
|
|
193
|
+
return delivery unless delivery.is_a?(Hash)
|
|
194
|
+
block = delivery["worktree"]
|
|
195
|
+
return delivery unless block.is_a?(Hash)
|
|
196
196
|
|
|
197
197
|
if remove
|
|
198
|
-
slug = slug_for_store(
|
|
198
|
+
slug = slug_for_store(delivery.dig("intent", "store").to_s, home: home)
|
|
199
199
|
repo = repo_for(slug, home: home)
|
|
200
200
|
|
|
201
201
|
remove_worktree(runner, repo: repo, worktree: block["code"]) if repo && block["code"]
|
|
202
202
|
prune(runner, repo: repo) if repo
|
|
203
203
|
end
|
|
204
204
|
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
delivery.delete("worktree")
|
|
206
|
+
delivery
|
|
207
207
|
end
|
|
208
208
|
|
|
209
209
|
# --- cleanup policy (merge-vs-remove) -------------------------------------
|
|
@@ -222,19 +222,19 @@ module Worktree
|
|
|
222
222
|
# Fail-open and idempotent throughout: a missing block, missing branch, or any
|
|
223
223
|
# git failure never raises and never blocks teardown. All git ops use
|
|
224
224
|
# `git -C <path>`, never cwd (decision D6). No-op when nothing was provisioned.
|
|
225
|
-
def finish(
|
|
226
|
-
return
|
|
227
|
-
block =
|
|
228
|
-
return
|
|
225
|
+
def finish(delivery, home: Dir.home, runner: ShellRunner.new, merge: false)
|
|
226
|
+
return delivery unless delivery.is_a?(Hash)
|
|
227
|
+
block = delivery["worktree"]
|
|
228
|
+
return delivery unless block.is_a?(Hash)
|
|
229
229
|
|
|
230
230
|
if merge
|
|
231
|
-
slug = slug_for_store(
|
|
231
|
+
slug = slug_for_store(delivery.dig("intent", "store").to_s, home: home)
|
|
232
232
|
repo = repo_for(slug, home: home)
|
|
233
233
|
branch = block["code_branch"]
|
|
234
234
|
merge_branch(runner, repo: repo, branch: branch) if repo && !blank?(branch)
|
|
235
235
|
end
|
|
236
236
|
|
|
237
|
-
release(
|
|
237
|
+
release(delivery, home: home, runner: runner, remove: true)
|
|
238
238
|
end
|
|
239
239
|
|
|
240
240
|
# Merge `branch` into the repo's default branch from the main checkout. The
|
|
@@ -298,8 +298,8 @@ module Worktree
|
|
|
298
298
|
# --- lock ------------------------------------------------------------------
|
|
299
299
|
|
|
300
300
|
# True iff ANOTHER session's delivery.lock is FRESH on this intent's dir
|
|
301
|
-
# (intent 108, D2): the durable lock file decides
|
|
302
|
-
#
|
|
301
|
+
# (intent 108, D2): the durable lock file decides alone, and no pid is
|
|
302
|
+
# probed. current_session being the owner or a
|
|
303
303
|
# delegate does not count as "other". A stale lock does not hold (explicit
|
|
304
304
|
# takeover reclaims it).
|
|
305
305
|
def lock_held_by_other?(intent_id:, store:, current_session:, home: Dir.home,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require_relative "worktree"
|
|
5
|
-
require_relative "
|
|
5
|
+
require_relative "index_entry"
|
|
6
6
|
|
|
7
7
|
# WorktreeSweep -- the one-time orphan sweep for store worktrees retired by
|
|
8
8
|
# intent 178 (D2). Pure candidate classification plus a thin apply step, both
|
|
@@ -62,7 +62,7 @@ module WorktreeSweep
|
|
|
62
62
|
|
|
63
63
|
# The INDEX.md section heading (e.g. "Active", "Completed") that lists this
|
|
64
64
|
# intent dir, or nil if no INDEX.md entry links to it. Reuses
|
|
65
|
-
#
|
|
65
|
+
# IndexEntry.match so this never drifts from the shared parser.
|
|
66
66
|
def index_status(intent_dir, name)
|
|
67
67
|
store = File.dirname(intent_dir)
|
|
68
68
|
index = File.join(File.dirname(store), "INDEX.md")
|
|
@@ -75,7 +75,7 @@ module WorktreeSweep
|
|
|
75
75
|
section = stripped.sub(/\A##\s*/, "").strip
|
|
76
76
|
next
|
|
77
77
|
end
|
|
78
|
-
m =
|
|
78
|
+
m = IndexEntry.match(stripped)
|
|
79
79
|
next unless m
|
|
80
80
|
return section if m[3].to_s.include?(name)
|
|
81
81
|
end
|
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|
|
|
@@ -2,30 +2,30 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
require_relative "lib/
|
|
5
|
+
require_relative "lib/node_input"
|
|
6
6
|
require_relative "lib/savepoint"
|
|
7
7
|
|
|
8
|
-
# node-
|
|
9
|
-
# whole input from disk and writes it to
|
|
8
|
+
# node-input - the CLI over NodeInput (intent 338, G5). Builds one node's
|
|
9
|
+
# whole input from disk and writes it to attempts/<node>--a<N>.input (or
|
|
10
10
|
# --out), printing a parsable summary and the exact node-transition running
|
|
11
11
|
# command intent 340's runner should record.
|
|
12
12
|
#
|
|
13
13
|
# Usage:
|
|
14
|
-
# node-
|
|
14
|
+
# node-input <intent_dir> --node <id> [--budget N] [--hop-tokens N]
|
|
15
15
|
# [--holder H] [--expires E] [--model M] [--attempt N]
|
|
16
16
|
# [--out PATH] [--force]
|
|
17
17
|
#
|
|
18
18
|
# Exit codes (spec D17, shared with node-transition's family):
|
|
19
|
-
# 0 - the
|
|
19
|
+
# 0 - the node input was written (or the identical bytes already existed)
|
|
20
20
|
# 2 - usage: not an intent directory, missing --node, or an unknown node
|
|
21
21
|
# 3 - an unreadable, cyclic or unparsable graph, node file, or record
|
|
22
22
|
# 4 - overflow past the third cut; nothing was written
|
|
23
23
|
# 5 - an existing attempt file whose bytes differ; nothing was written
|
|
24
|
-
module
|
|
24
|
+
module NodeInputCLI
|
|
25
25
|
module_function
|
|
26
26
|
|
|
27
27
|
def usage
|
|
28
|
-
warn "Usage: node-
|
|
28
|
+
warn "Usage: node-input <intent_dir> --node <id> [--budget N] [--hop-tokens N] " \
|
|
29
29
|
"[--holder H] [--expires E] [--model M] [--attempt N] [--out PATH] [--force]"
|
|
30
30
|
end
|
|
31
31
|
|
|
@@ -42,7 +42,7 @@ module NodePacketCLI
|
|
|
42
42
|
intent_dir_arg = args.shift
|
|
43
43
|
|
|
44
44
|
unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
|
|
45
|
-
warn "node-
|
|
45
|
+
warn "node-input: #{intent_dir_arg.inspect} is not an intent directory"
|
|
46
46
|
usage
|
|
47
47
|
exit 2
|
|
48
48
|
end
|
|
@@ -50,7 +50,7 @@ module NodePacketCLI
|
|
|
50
50
|
|
|
51
51
|
node = opt(args, "--node")
|
|
52
52
|
if node.to_s.strip.empty?
|
|
53
|
-
warn "node-
|
|
53
|
+
warn "node-input: --node is required"
|
|
54
54
|
usage
|
|
55
55
|
exit 2
|
|
56
56
|
end
|
|
@@ -64,11 +64,11 @@ module NodePacketCLI
|
|
|
64
64
|
model = opt(args, "--model")
|
|
65
65
|
force = args.include?("--force")
|
|
66
66
|
|
|
67
|
-
result =
|
|
67
|
+
result = NodeInput.build(
|
|
68
68
|
intent_dir: intent_dir,
|
|
69
69
|
node: node,
|
|
70
|
-
budget_tokens: budget ? budget.to_i :
|
|
71
|
-
hop_tokens: hop_tokens ? hop_tokens.to_i :
|
|
70
|
+
budget_tokens: budget ? budget.to_i : NodeInput::DEFAULT_BUDGET_TOKENS,
|
|
71
|
+
hop_tokens: hop_tokens ? hop_tokens.to_i : NodeInput::DEFAULT_HOP_TOKENS,
|
|
72
72
|
holder: holder,
|
|
73
73
|
expires: expires,
|
|
74
74
|
model: model,
|
|
@@ -78,15 +78,15 @@ module NodePacketCLI
|
|
|
78
78
|
)
|
|
79
79
|
|
|
80
80
|
if result[:ok]
|
|
81
|
-
puts
|
|
81
|
+
puts NodeInput.summary_line(result)
|
|
82
82
|
puts result[:running_command]
|
|
83
83
|
exit 0
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
Array(result[:errors]).each { |e| warn "node-
|
|
86
|
+
Array(result[:errors]).each { |e| warn "node-input: #{e}" }
|
|
87
87
|
puts result[:needs_decision_command] if result[:needs_decision_command]
|
|
88
88
|
exit result[:exit_code]
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
NodeInputCLI.main(ARGV) if $PROGRAM_NAME == __FILE__
|
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_input"
|
|
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 node input 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 node input 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 node input the running line names is missing from disk
|
|
45
|
+
# 6 - the node input on disk does not hash to the running line's input=
|
|
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>/attempts/<node>--a<N>.return.yml (spec): beside the node input,
|
|
64
|
+
# one per attempt, the same treatment input 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, "attempts", "#{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 NodeInput'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 node input was originally built).
|
|
176
|
+
attempt = NodeInput.compute_attempt_number(intent_dir: intent_dir, node: node, lease_flag_given: false)
|
|
177
|
+
input_path = NodeInput.input_path(intent_dir: intent_dir, node: node, attempt: attempt)
|
|
178
|
+
|
|
179
|
+
unless File.exist?(input_path)
|
|
180
|
+
warn "node-run: #{node}'s node input is missing at #{input_path}"
|
|
181
|
+
exit 5
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
input_bytes = File.binread(input_path)
|
|
185
|
+
actual_sha = Digest::SHA256.hexdigest(input_bytes)[0, 12]
|
|
186
|
+
expected_sha = fields["input"]
|
|
187
|
+
unless expected_sha && actual_sha == expected_sha
|
|
188
|
+
warn "node-run: #{node}'s node input at #{input_path} does not hash to input=#{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: input_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__
|