@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
|
@@ -149,7 +149,7 @@ module IntentValidator
|
|
|
149
149
|
|
|
150
150
|
# PURE: combine the frontmatter result with section-structure findings for a
|
|
151
151
|
# content STRING. Returns the frontmatter result hash extended with
|
|
152
|
-
# :section_missing, :section_unknown, and
|
|
152
|
+
# :section_missing, :section_unknown, and merged section errors; :ok is the AND
|
|
153
153
|
# of frontmatter and sections. Lets the create gate validate proposed content
|
|
154
154
|
# (no file on disk) with the same definition as the CLI and doctor.
|
|
155
155
|
def validate_content(content, known_stores: nil)
|
|
@@ -158,7 +158,7 @@ module IntentValidator
|
|
|
158
158
|
merge_sections(fm_result, sections)
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
#
|
|
161
|
+
# Merge section findings into a frontmatter result hash (shared by validate and
|
|
162
162
|
# validate_content). Does not mutate the input.
|
|
163
163
|
def merge_sections(fm_result, sections)
|
|
164
164
|
errors = fm_result[:errors].dup
|
package/scripts/lib/lock.rb
CHANGED
|
@@ -12,8 +12,8 @@ require "time"
|
|
|
12
12
|
# savepoint.md (git-ignored, transient state). Ownership is session-keyed (D1):
|
|
13
13
|
# the file records the owner session, never a pid. Liveness is a lease: the
|
|
14
14
|
# owner's hooks touch the file mtime on tool calls (heartbeat); the lock is
|
|
15
|
-
# stale only when that heartbeat is older than the TTL.
|
|
16
|
-
#
|
|
15
|
+
# stale only when that heartbeat is older than the TTL. This lock file is
|
|
16
|
+
# the sole truth of ownership (D2).
|
|
17
17
|
#
|
|
18
18
|
# Mutual-exclusion seam (D3): the schema carries a type ("delivery" now,
|
|
19
19
|
# "maintenance" in a chained intent after 93) and acquire refuses while the
|
|
@@ -27,13 +27,12 @@ module Lock
|
|
|
27
27
|
# Skill-invocation prefix per harness (intent 201, D2/D3). Claude Code invokes a
|
|
28
28
|
# skill with a slash (/plastic-doctor); Codex CLI invokes explicitly with a
|
|
29
29
|
# dollar ($plastic-doctor) and may also select one implicitly by matching the
|
|
30
|
-
# skill's description. This table is the
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# already stay clear of installer_core.rb (spec Alternatives Considered).
|
|
30
|
+
# skill's description. This table is the package-wide source of truth for
|
|
31
|
+
# `Lock.skill_ref`; every caller reaches it directly rather than duplicating
|
|
32
|
+
# the prefix table. InstallerCore::DEFAULT_AGENTS carries the same values per
|
|
33
|
+
# adapter as documented config (see ACTION_2); this constant is not read from
|
|
34
|
+
# it at runtime, by the same reasoning this file and hook-* already stay
|
|
35
|
+
# clear of installer_core.rb (spec Alternatives Considered).
|
|
37
36
|
SKILL_PREFIXES = { "claude" => "/", "codex" => "$" }.freeze
|
|
38
37
|
|
|
39
38
|
# Renders a skill reference for the given harness. Unset or unrecognized
|
|
@@ -142,30 +142,36 @@ class MeterWatch
|
|
|
142
142
|
# with launchctl (scripts/meter-watch --install-timer calls this and
|
|
143
143
|
# nothing else). RunAtLoad primes the first tick; StartInterval repeats
|
|
144
144
|
# it every 20 minutes.
|
|
145
|
-
|
|
145
|
+
#
|
|
146
|
+
# `label:` and `arguments:` (intent 340a, G7b, n3, graph.md D9) let the
|
|
147
|
+
# delivery watch's own `runner watch --install-timer` reuse this one
|
|
148
|
+
# writer for its `com.plastic.delivery-watch.<id>` job instead of
|
|
149
|
+
# rendering plist XML a second time; their defaults reproduce today's
|
|
150
|
+
# meter-watch plist byte for byte (row 3.1).
|
|
151
|
+
def install_timer(home:, script_path:, ruby: RbConfig.ruby, interval: TICK_SECONDS,
|
|
152
|
+
label: "com.plastic.meter-watch", arguments: nil)
|
|
146
153
|
agents_dir = File.join(home, "Library", "LaunchAgents")
|
|
147
154
|
FileUtils.mkdir_p(agents_dir)
|
|
148
|
-
plist_path = File.join(agents_dir, "
|
|
149
|
-
|
|
155
|
+
plist_path = File.join(agents_dir, "#{label}.plist")
|
|
156
|
+
args = arguments || [ruby, script_path, "--home", home]
|
|
157
|
+
File.write(plist_path, plist(label, args, interval))
|
|
150
158
|
plist_path
|
|
151
159
|
end
|
|
152
160
|
|
|
153
161
|
private
|
|
154
162
|
|
|
155
|
-
def plist(
|
|
163
|
+
def plist(label, arguments, interval)
|
|
164
|
+
args_xml = arguments.map { |a| " <string>#{a}</string>" }.join("\n")
|
|
156
165
|
<<~XML
|
|
157
166
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
158
167
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
159
168
|
<plist version="1.0">
|
|
160
169
|
<dict>
|
|
161
170
|
<key>Label</key>
|
|
162
|
-
<string
|
|
171
|
+
<string>#{label}</string>
|
|
163
172
|
<key>ProgramArguments</key>
|
|
164
173
|
<array>
|
|
165
|
-
|
|
166
|
-
<string>#{script_path}</string>
|
|
167
|
-
<string>--home</string>
|
|
168
|
-
<string>#{home}</string>
|
|
174
|
+
#{args_xml}
|
|
169
175
|
</array>
|
|
170
176
|
<key>StartInterval</key>
|
|
171
177
|
<integer>#{interval}</integer>
|
package/scripts/lib/node_file.rb
CHANGED
|
@@ -102,14 +102,14 @@ module NodeFile
|
|
|
102
102
|
# <id>.md or <id>--<slug>.md, exactly - a longer id's file (n11--x.md) must
|
|
103
103
|
# never satisfy a shorter id (n1), so the id is matched as the whole prefix
|
|
104
104
|
# up to end-of-string or the literal "--" separator, never as a substring
|
|
105
|
-
# (
|
|
105
|
+
# (review A9).
|
|
106
106
|
def filename_matches_id?(basename, id)
|
|
107
107
|
basename.match?(/\A#{Regexp.escape(id)}(--[A-Za-z0-9][A-Za-z0-9-]*)?\z/)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
# budget: normalizes to a plain integer token ceiling. A bare integer and
|
|
111
111
|
# {tokens: N} both normalize to N; {turns: N} is the field 327 D47 removed
|
|
112
|
-
# and is a named error, never silently accepted (
|
|
112
|
+
# and is a named error, never silently accepted (from report's example).
|
|
113
113
|
def normalize_budget(raw)
|
|
114
114
|
return [nil, []] if raw.nil?
|
|
115
115
|
return [raw, []] if raw.is_a?(Integer)
|
|
@@ -128,7 +128,7 @@ module NodeFile
|
|
|
128
128
|
|
|
129
129
|
# Every [heading_line, body] pair in the body text, split on any heading
|
|
130
130
|
# line, fence-aware: a "#" line inside a fenced block never starts a new
|
|
131
|
-
# section (
|
|
131
|
+
# section (see: "a node passes on a section it does not have").
|
|
132
132
|
def split_by_headings(text)
|
|
133
133
|
sections = []
|
|
134
134
|
heading = nil
|
|
@@ -10,18 +10,18 @@ require_relative "graph_file"
|
|
|
10
10
|
require_relative "node_ledger"
|
|
11
11
|
require_relative "savepoint"
|
|
12
12
|
require_relative "insights"
|
|
13
|
-
require_relative "
|
|
13
|
+
require_relative "data_boundary"
|
|
14
14
|
require_relative "atomic_write"
|
|
15
15
|
require_relative "arm"
|
|
16
16
|
|
|
17
|
-
#
|
|
17
|
+
# NodeInput (intent 338, G5): builds a node's whole input from disk, the
|
|
18
18
|
# five blocks 327 section 8 fixed - the node, the ledger, the record, the
|
|
19
19
|
# knowledge hop, and where to work. n2 (this half) is the readers: every one
|
|
20
20
|
# of them a keyword seam with a real default, so the suite never shells out
|
|
21
21
|
# to git, never reads the owner's real store, and never sets an environment
|
|
22
|
-
# variable (spec D16). n3 adds assembly, the budget, and the
|
|
22
|
+
# variable (spec D16). n3 adds assembly, the budget, and the node input's
|
|
23
23
|
# identity on top of the same file.
|
|
24
|
-
module
|
|
24
|
+
module NodeInput
|
|
25
25
|
module_function
|
|
26
26
|
|
|
27
27
|
DEFAULT_BUDGET_TOKENS = 8000
|
|
@@ -31,11 +31,11 @@ module NodePacket
|
|
|
31
31
|
INSIGHTS_KEEP = 3
|
|
32
32
|
|
|
33
33
|
# C7's "the executor stops without it" (spec D9), rendered whenever a
|
|
34
|
-
#
|
|
34
|
+
# node input carries no lease and whenever the worktree Arm.worktree_block
|
|
35
35
|
# reports is not provisioned.
|
|
36
36
|
STOP_DIRECTIVE = "STOP: no lease is recorded for this node. Do not edit files or run any command until a runner dispatches this node with a holder, an expiry and a model."
|
|
37
37
|
|
|
38
|
-
# Pinned so `
|
|
38
|
+
# Pinned so `input=<sha>` is a function of the repo's history alone
|
|
39
39
|
# (post-execution review finding B4): unpinned, `git log --stat` varies
|
|
40
40
|
# with the terminal's COLUMNS (abbreviates paths, narrows the graph
|
|
41
41
|
# column), the caller's `color.ui` (ANSI escapes land in the ledger data
|
|
@@ -57,7 +57,7 @@ module NodePacket
|
|
|
57
57
|
|
|
58
58
|
# `landed commits` is one of the three never-cut blocks (matrix 3.9), so an
|
|
59
59
|
# unbounded `git log --stat` (ten verbose commit messages, say) could route
|
|
60
|
-
# the whole
|
|
60
|
+
# the whole node input straight to exit 4 with no cut able to help.
|
|
61
61
|
def truncate_landed_commits(out)
|
|
62
62
|
return out.to_s if out.to_s.bytesize <= LANDED_COMMITS_MAX_BYTES
|
|
63
63
|
|
|
@@ -158,7 +158,7 @@ module NodePacket
|
|
|
158
158
|
# of the post-execution review): `node-transition` is a concurrent
|
|
159
159
|
# appending writer, so re-reading `savepoint.md` once per predecessor let a
|
|
160
160
|
# line landing mid-build make the Transitions, Lease and attempt number of
|
|
161
|
-
# one
|
|
161
|
+
# one node input disagree with each other. `build` reads once and threads the
|
|
162
162
|
# same entries through every block; a direct caller with no entries to
|
|
163
163
|
# share still gets a real default that reads the file itself.
|
|
164
164
|
def predecessor_block(intent_dir:, node:, graph_reader: GraphFile.method(:parse), entries: nil)
|
|
@@ -186,12 +186,12 @@ module NodePacket
|
|
|
186
186
|
end
|
|
187
187
|
private_class_method :last_running_entry
|
|
188
188
|
|
|
189
|
-
# Whether the
|
|
189
|
+
# Whether the node input renders no lease at all (matrix 2.11a, post-execution
|
|
190
190
|
# review finding A1): neither a flag-supplied lease nor a recorded
|
|
191
191
|
# `running` line for this node. `lease_block` itself only ever renders
|
|
192
192
|
# `lease: none` for this case (spec D3: block 2 is retrieved data, and C7's
|
|
193
193
|
# stop directive is instruction, so it can never live inside that data
|
|
194
|
-
# block, on pain of being self-cancelling under the
|
|
194
|
+
# block, on pain of being self-cancelling under the node input's own trust
|
|
195
195
|
# rule). `build` uses this to decide whether the stop directive belongs in
|
|
196
196
|
# block 5 instead, deduplicated against the worktree's own copy.
|
|
197
197
|
def lease_missing?(node:, holder:, expires:, model:, entries:)
|
|
@@ -304,12 +304,12 @@ module NodePacket
|
|
|
304
304
|
chunks = Array(sources).map { |src| hop_chunk(store_dir, src) }
|
|
305
305
|
text = chunks.join("\n\n").scrub
|
|
306
306
|
cap = hop_tokens.to_i
|
|
307
|
-
tokens =
|
|
307
|
+
tokens = DataBoundary.estimate_tokens(text)
|
|
308
308
|
if tokens > cap
|
|
309
309
|
note = "\n[hop truncated at #{cap} tokens]"
|
|
310
310
|
max_bytes = [(cap * 4) - note.bytesize, 0].max
|
|
311
311
|
text = "#{safe_byteslice(text, max_bytes)}#{note}"
|
|
312
|
-
tokens =
|
|
312
|
+
tokens = DataBoundary.estimate_tokens(text)
|
|
313
313
|
end
|
|
314
314
|
{ text: text, tokens: tokens }
|
|
315
315
|
end
|
|
@@ -401,7 +401,7 @@ module NodePacket
|
|
|
401
401
|
# `permitted_classes` (post-execution review finding B3): the same bug
|
|
402
402
|
# class `record_sources` already fixed once in 607e31e. Any project.yml
|
|
403
403
|
# that gains a date-typed value (a `created:` field, say) silently
|
|
404
|
-
# stripped the test command from every
|
|
404
|
+
# stripped the test command from every node input for that project, since
|
|
405
405
|
# the rescue swallowed `Psych::DisallowedClass` and returned nil.
|
|
406
406
|
data = begin
|
|
407
407
|
YAML.safe_load(File.read(path), permitted_classes: [Date, Time])
|
|
@@ -439,14 +439,14 @@ module NodePacket
|
|
|
439
439
|
end
|
|
440
440
|
|
|
441
441
|
# `lease_missing` (post-execution review finding A1) hoists C7's stop
|
|
442
|
-
# directive here, block 5 (instruction, spec D3), whenever the
|
|
442
|
+
# directive here, block 5 (instruction, spec D3), whenever the node input
|
|
443
443
|
# carries no lease. `worktree_block` already renders its own copy when the
|
|
444
444
|
# worktree is unprovisioned; the two conditions often fire together, so a
|
|
445
445
|
# directive already present is never repeated.
|
|
446
446
|
#
|
|
447
447
|
# `call_cap` (intent 355, n2, D2): one sentence naming this attempt's tool
|
|
448
448
|
# call cap and the return it hits at, so the executor learns the number
|
|
449
|
-
# from the
|
|
449
|
+
# from the node input it starts with, never from a denied call mid-edit
|
|
450
450
|
# (matrix 2.4). nil (a caller that names no cap) renders nothing here.
|
|
451
451
|
def where_to_work_block(intent_dir:, worktree_reader: Arm.method(:worktree_block),
|
|
452
452
|
project_reader: method(:default_project_reader), lease_missing: false, call_cap: nil,
|
|
@@ -563,7 +563,7 @@ module NodePacket
|
|
|
563
563
|
end
|
|
564
564
|
|
|
565
565
|
# ===========================================================================
|
|
566
|
-
# n3: assembly, the budget, and the
|
|
566
|
+
# n3: assembly, the budget, and the node input's identity
|
|
567
567
|
# ===========================================================================
|
|
568
568
|
|
|
569
569
|
# Block labels/sources for the wrapped (data) blocks, spec D3.
|
|
@@ -610,7 +610,7 @@ module NodePacket
|
|
|
610
610
|
"#{parts.join("\n")}\n"
|
|
611
611
|
end
|
|
612
612
|
|
|
613
|
-
# One
|
|
613
|
+
# One node input, node and where-to-work as plain instruction text, ledger,
|
|
614
614
|
# record and (when present) hop wrapped as labeled data sharing ONE
|
|
615
615
|
# boundary token computed over their raw payloads (spec D2-D4, matrix
|
|
616
616
|
# 3.1-3.3).
|
|
@@ -618,22 +618,22 @@ module NodePacket
|
|
|
618
618
|
# Blocks 1 and 5 are raw-interpolated (spec D3: instruction, not data), but
|
|
619
619
|
# that trust does not reach a `release.verify` a project.yml can carry
|
|
620
620
|
# (post-execution review finding A2): a line that is, on its own, a
|
|
621
|
-
# complete data marker is disarmed by `
|
|
622
|
-
# before it ever reaches the
|
|
621
|
+
# complete data marker is disarmed by `DataBoundary.neutralize_marker_lines`
|
|
622
|
+
# before it ever reaches the node input, so a forged marker cannot open a
|
|
623
623
|
# block outside the wrapper's own boundary. `record_source`/`hop_source`
|
|
624
624
|
# (finding C15) are the record's and the hop sources' real store-relative
|
|
625
625
|
# paths, never the placeholder label "record"/"sources".
|
|
626
|
-
def
|
|
626
|
+
def render_input(node_text:, ledger_text:, intent_text:, decisions:, insights:, hop:, where_text:,
|
|
627
627
|
record_source: "record", hop_source: "sources")
|
|
628
628
|
record_text = render_record_text(intent: intent_text, decisions: decisions, insights: insights)
|
|
629
629
|
hop_text = hop && hop[:text]
|
|
630
630
|
|
|
631
631
|
payloads = [ledger_text, record_text]
|
|
632
632
|
payloads << hop_text if hop_text
|
|
633
|
-
token =
|
|
633
|
+
token = DataBoundary.boundary_token(payloads)
|
|
634
634
|
|
|
635
|
-
node_text_safe =
|
|
636
|
-
where_text_safe =
|
|
635
|
+
node_text_safe = DataBoundary.neutralize_marker_lines(node_text.to_s)
|
|
636
|
+
where_text_safe = DataBoundary.neutralize_marker_lines(where_text.to_s)
|
|
637
637
|
|
|
638
638
|
# Normalized through `attr_safe` exactly as `wrap` normalizes the marker
|
|
639
639
|
# attributes it writes (post-execution review finding A2's integrity
|
|
@@ -641,43 +641,43 @@ module NodePacket
|
|
|
641
641
|
# `unwrap` reads back off the rendered marker line raised on every nil
|
|
642
642
|
# source, because `attr_safe(nil)` renders as `""`, not `"nil".to_s`.
|
|
643
643
|
wrapped_specs = [
|
|
644
|
-
{ label:
|
|
645
|
-
{ label:
|
|
644
|
+
{ label: DataBoundary.attr_safe(LEDGER_LABEL), source: DataBoundary.attr_safe("savepoint.md") },
|
|
645
|
+
{ label: DataBoundary.attr_safe(RECORD_LABEL), source: DataBoundary.attr_safe(record_source) },
|
|
646
646
|
]
|
|
647
|
-
wrapped_specs << { label:
|
|
647
|
+
wrapped_specs << { label: DataBoundary.attr_safe(HOP_LABEL), source: DataBoundary.attr_safe(hop_source) } if hop_text
|
|
648
648
|
|
|
649
649
|
parts = [node_text_safe.rstrip, ""]
|
|
650
|
-
parts <<
|
|
650
|
+
parts << DataBoundary.wrap(ledger_text, label: LEDGER_LABEL, source: "savepoint.md", token: token).rstrip
|
|
651
651
|
parts << ""
|
|
652
|
-
parts <<
|
|
652
|
+
parts << DataBoundary.wrap(record_text, label: RECORD_LABEL, source: record_source, token: token).rstrip
|
|
653
653
|
parts << ""
|
|
654
654
|
if hop_text
|
|
655
|
-
parts <<
|
|
655
|
+
parts << DataBoundary.wrap(hop_text, label: HOP_LABEL, source: hop_source, token: token).rstrip
|
|
656
656
|
parts << ""
|
|
657
657
|
end
|
|
658
658
|
parts << where_text_safe.rstrip
|
|
659
659
|
rendered = "#{parts.join("\n")}\n"
|
|
660
660
|
|
|
661
|
-
|
|
661
|
+
assert_input_integrity!(rendered, wrapped_specs)
|
|
662
662
|
rendered
|
|
663
663
|
end
|
|
664
664
|
|
|
665
665
|
# The trust boundary's own invariant (post-execution review finding A2):
|
|
666
|
-
# the finished
|
|
666
|
+
# the finished node input must unwrap to exactly the data blocks that were
|
|
667
667
|
# wrapped, same count, same labels, same sources, in order. This is what
|
|
668
668
|
# the escaping rule and the marker-line neutralization pass are FOR, so the
|
|
669
669
|
# check belongs here, not only in a test that could rot independently of
|
|
670
670
|
# the code it is meant to guard.
|
|
671
|
-
def
|
|
672
|
-
actual =
|
|
671
|
+
def assert_input_integrity!(rendered, wrapped_specs)
|
|
672
|
+
actual = DataBoundary.unwrap(rendered).map { |b| { label: b[:label], source: b[:source] } }
|
|
673
673
|
return if actual == wrapped_specs
|
|
674
674
|
|
|
675
|
-
raise "node
|
|
675
|
+
raise "node input integrity check failed: expected #{wrapped_specs.inspect}, got #{actual.inspect}"
|
|
676
676
|
end
|
|
677
|
-
private_class_method :
|
|
677
|
+
private_class_method :assert_input_integrity!
|
|
678
678
|
|
|
679
679
|
def render_from_state(state)
|
|
680
|
-
|
|
680
|
+
render_input(node_text: state[:node_text], ledger_text: state[:ledger_text], intent_text: state[:intent_text],
|
|
681
681
|
decisions: state[:decisions], insights: state[:insights], hop: state[:hop],
|
|
682
682
|
where_text: state[:where_text], record_source: state[:record_source] || "record",
|
|
683
683
|
hop_source: state[:hop_source] || "sources")
|
|
@@ -685,7 +685,7 @@ module NodePacket
|
|
|
685
685
|
private_class_method :render_from_state
|
|
686
686
|
|
|
687
687
|
def estimate_rendered_tokens(rendered)
|
|
688
|
-
|
|
688
|
+
DataBoundary.estimate_tokens(rendered)
|
|
689
689
|
end
|
|
690
690
|
|
|
691
691
|
# The C28 cut ladder: drop the hop whole, then cut Insights to the last
|
|
@@ -748,8 +748,8 @@ module NodePacket
|
|
|
748
748
|
"record" => record_text,
|
|
749
749
|
"where to work" => state[:where_text],
|
|
750
750
|
}
|
|
751
|
-
name, text = candidates.max_by { |_, t|
|
|
752
|
-
[name,
|
|
751
|
+
name, text = candidates.max_by { |_, t| DataBoundary.estimate_tokens(t.to_s) }
|
|
752
|
+
[name, DataBoundary.estimate_tokens(text.to_s)]
|
|
753
753
|
end
|
|
754
754
|
private_class_method :name_oversized_block
|
|
755
755
|
|
|
@@ -760,10 +760,10 @@ module NodePacket
|
|
|
760
760
|
|
|
761
761
|
# C21: the number of `running` lines already recorded for `node`, plus one
|
|
762
762
|
# when a lease is being supplied by flag (a NEW dispatch), floored at 1.
|
|
763
|
-
# Minor 5: a torn `running` line (missing holder=/expires=/
|
|
763
|
+
# Minor 5: a torn `running` line (missing holder=/expires=/input=/model=)
|
|
764
764
|
# is skipped here exactly as ReadySet.attempts_count already skips it -
|
|
765
765
|
# counting it desynchronizes this attempt number from the extensions file
|
|
766
|
-
# a live node's own `
|
|
766
|
+
# a live node's own `attempts/<node>--a<N>.extensions` names, since that
|
|
767
767
|
# file is keyed by the attempt sweep computed off the SAME filtered count.
|
|
768
768
|
def compute_attempt_number(intent_dir:, node:, lease_flag_given:, entries: nil)
|
|
769
769
|
entries ||= NodeLedger.entries(savepoint_path(intent_dir))
|
|
@@ -771,8 +771,8 @@ module NodePacket
|
|
|
771
771
|
[count + (lease_flag_given ? 1 : 0), 1].max
|
|
772
772
|
end
|
|
773
773
|
|
|
774
|
-
def
|
|
775
|
-
File.join(intent_dir, "
|
|
774
|
+
def input_path(intent_dir:, node:, attempt:)
|
|
775
|
+
File.join(intent_dir, "attempts", "#{node}--a#{attempt}.input")
|
|
776
776
|
end
|
|
777
777
|
|
|
778
778
|
def summary_line(result)
|
|
@@ -781,7 +781,7 @@ module NodePacket
|
|
|
781
781
|
end
|
|
782
782
|
|
|
783
783
|
def running_command(intent_dir:, node:, sha:, hop_tokens:)
|
|
784
|
-
"node-transition #{intent_dir} --node #{node} --state running --field
|
|
784
|
+
"node-transition #{intent_dir} --node #{node} --state running --field input=#{sha} --field hop=#{hop_tokens}"
|
|
785
785
|
end
|
|
786
786
|
|
|
787
787
|
def needs_decision_command(intent_dir:, node:, question:)
|
|
@@ -789,7 +789,7 @@ module NodePacket
|
|
|
789
789
|
"node-transition #{intent_dir} --node #{node} --state needs_decision --field question=\"#{escaped}\""
|
|
790
790
|
end
|
|
791
791
|
|
|
792
|
-
# Build one node's whole
|
|
792
|
+
# Build one node's whole input from disk (spec D1). Returns
|
|
793
793
|
# {ok:, exit_code:, path:, sha:, tokens:, hop_tokens:, attempt:,
|
|
794
794
|
# cuts_applied:, running_command:, errors:} on success, or
|
|
795
795
|
# {ok: false, exit_code:, errors:, needs_decision_command: (on overflow)}
|
|
@@ -831,7 +831,7 @@ module NodePacket
|
|
|
831
831
|
# it (post-execution review finding B12): `node-transition` is a
|
|
832
832
|
# concurrent appending writer, so re-reading `savepoint.md` once per
|
|
833
833
|
# block risked a line landing mid-build and making the Transitions,
|
|
834
|
-
# Lease and attempt number of one
|
|
834
|
+
# Lease and attempt number of one node input disagree with each other.
|
|
835
835
|
entries = NodeLedger.entries(savepoint_path(intent_dir))
|
|
836
836
|
|
|
837
837
|
ledger_text = full_ledger_text(intent_dir: intent_dir, node: node, files: nb[:files], holder: holder,
|
|
@@ -843,7 +843,7 @@ module NodePacket
|
|
|
843
843
|
hop_full = hop_block(store_dir: store_dir, sources: sources, hop_tokens: hop_tokens)
|
|
844
844
|
|
|
845
845
|
# Finding A1: the stop directive belongs in block 5 (instruction)
|
|
846
|
-
# whenever the
|
|
846
|
+
# whenever the node input carries no lease at all, never inside block 2's
|
|
847
847
|
# ledger data (spec D3's self-cancellation risk).
|
|
848
848
|
missing_lease = lease_missing?(node: node, holder: holder, expires: expires, model: model, entries: entries)
|
|
849
849
|
where_text = where_to_work_block(intent_dir: intent_dir, worktree_reader: worktree_reader,
|
|
@@ -867,7 +867,7 @@ module NodePacket
|
|
|
867
867
|
decisions: cuts_applied.include?(:decisions) ? state[:decisions].last(DECISIONS_KEEP) : state[:decisions],
|
|
868
868
|
)
|
|
869
869
|
oversized_name, oversized_tokens = name_oversized_block(final_state)
|
|
870
|
-
question = "
|
|
870
|
+
question = "node input for #{node} is #{tokens} tokens after every cut, over the #{budget_tokens}-token " \
|
|
871
871
|
"budget; #{oversized_name} alone is #{oversized_tokens} tokens, shorten it"
|
|
872
872
|
return {
|
|
873
873
|
ok: false, exit_code: 4, tokens: tokens,
|
|
@@ -878,7 +878,7 @@ module NodePacket
|
|
|
878
878
|
|
|
879
879
|
attempt_n = attempt || compute_attempt_number(intent_dir: intent_dir, node: node,
|
|
880
880
|
lease_flag_given: lease_flag_given?(holder), entries: entries)
|
|
881
|
-
path = out ? File.expand_path(out) :
|
|
881
|
+
path = out ? File.expand_path(out) : input_path(intent_dir: intent_dir, node: node, attempt: attempt_n)
|
|
882
882
|
FileUtils.mkdir_p(File.dirname(path))
|
|
883
883
|
|
|
884
884
|
if File.exist?(path)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# NodeInputCompatibility (intent 338a, G6, n1, D10/D11): the one seam that
|
|
5
|
+
# lets a `running` line written before the node-input rename keep reading
|
|
6
|
+
# correctly. It is the ONLY shipped file allowed to name the retired field,
|
|
7
|
+
# and it names it exactly once, as LEGACY_FIELD below.
|
|
8
|
+
#
|
|
9
|
+
# It maps, never rewrites: the raw ledger line on disk is never touched.
|
|
10
|
+
# `NodeLedger.parse_transition_line` calls #fields on every parsed field hash
|
|
11
|
+
# before returning it, so torn detection, attempt counting, node-run,
|
|
12
|
+
# graph-measure and outcome-report all see `input` whether the line was
|
|
13
|
+
# written by an old dispatch or a new one.
|
|
14
|
+
#
|
|
15
|
+
# No project requires (spec D10): a plain module over a plain hash, so
|
|
16
|
+
# `node_ledger.rb` never gains a require cycle through this file.
|
|
17
|
+
module NodeInputCompatibility
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The retired field name, named once. No other shipped file names it.
|
|
21
|
+
LEGACY_FIELD = "packet"
|
|
22
|
+
|
|
23
|
+
# The retired directory and suffix (338a n3, D8), named once beside
|
|
24
|
+
# LEGACY_FIELD. No other shipped file spells either literal; a caller
|
|
25
|
+
# that needs the legacy input location builds it from these constants.
|
|
26
|
+
LEGACY_DIRECTORY = "packets"
|
|
27
|
+
LEGACY_SUFFIX = ".packet"
|
|
28
|
+
|
|
29
|
+
# A copy of `fields` where LEGACY_FIELD maps to "input" when "input" is
|
|
30
|
+
# absent or blank. "input" wins when both are present (D11): the newer,
|
|
31
|
+
# authoritative name is never overwritten by the retired one. LEGACY_FIELD
|
|
32
|
+
# itself is dropped from the copy either way, so a caller that renders
|
|
33
|
+
# every field it does not recognize (outcome-report's evidence line) never
|
|
34
|
+
# sees the retired key leak through as a second, unrecognized field.
|
|
35
|
+
def fields(fields)
|
|
36
|
+
source = fields || {}
|
|
37
|
+
return source.dup unless source.key?(LEGACY_FIELD)
|
|
38
|
+
|
|
39
|
+
mapped = source.dup
|
|
40
|
+
unless present?(mapped["input"])
|
|
41
|
+
mapped["input"] = mapped[LEGACY_FIELD]
|
|
42
|
+
end
|
|
43
|
+
mapped.delete(LEGACY_FIELD)
|
|
44
|
+
mapped
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 338a n3 (D8): the new path when it exists, the legacy directory and
|
|
48
|
+
# suffix when only that file exists, and the new path when neither does
|
|
49
|
+
# (a fresh attempt names its own soon-to-be-written destination).
|
|
50
|
+
def input_path(intent_dir:, node:, attempt:)
|
|
51
|
+
new_path = File.join(intent_dir, "attempts", "#{node}--a#{attempt}.input")
|
|
52
|
+
return new_path if File.exist?(new_path)
|
|
53
|
+
|
|
54
|
+
legacy_path = File.join(intent_dir, LEGACY_DIRECTORY, "#{node}--a#{attempt}#{LEGACY_SUFFIX}")
|
|
55
|
+
File.exist?(legacy_path) ? legacy_path : new_path
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def present?(value)
|
|
59
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
60
|
+
end
|
|
61
|
+
private_class_method :present?
|
|
62
|
+
end
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require "strscan"
|
|
5
5
|
require_relative "savepoint"
|
|
6
6
|
require_relative "guarded_append"
|
|
7
|
+
require_relative "node_input_compatibility"
|
|
7
8
|
|
|
8
9
|
# NodeLedger - the node and intent transition line (intent 335, G2). Owns the
|
|
9
10
|
# line's byte-exact format, the closed state vocabulary, the required fields per
|
|
@@ -40,7 +41,7 @@ module NodeLedger
|
|
|
40
41
|
# a flat list; DONE_EVIDENCE_FIELDS below carries the "at least one of" half.
|
|
41
42
|
REQUIRED_FIELDS = {
|
|
42
43
|
"planned" => [],
|
|
43
|
-
"running" => %w[holder expires
|
|
44
|
+
"running" => %w[holder expires input model],
|
|
44
45
|
"done" => %w[gates],
|
|
45
46
|
"failed_verification" => %w[gates reason],
|
|
46
47
|
"needs_decision" => %w[question],
|
|
@@ -61,7 +62,14 @@ module NodeLedger
|
|
|
61
62
|
# 2.6): every key named in REQUIRED_FIELDS or DONE_EVIDENCE_FIELDS, plus
|
|
62
63
|
# `model` (accepted on any state). A field outside this list still renders,
|
|
63
64
|
# sorted after these, so an unrecognized key is never silently dropped.
|
|
64
|
-
|
|
65
|
+
#
|
|
66
|
+
# `harness` (intent 340b, G7c, n1, D21) sits right after `model`: both are
|
|
67
|
+
# dispatch-time metadata `running` writes and every terminal transition
|
|
68
|
+
# carries forward, accepted on every state and required by none - the same
|
|
69
|
+
# treatment `suite=`, `hop=` and `core_drift=` already get as extras, given
|
|
70
|
+
# a stable declared position instead of falling to the alphabetical extras
|
|
71
|
+
# tail (matrix row 1.17).
|
|
72
|
+
FIELD_ORDER = %w[holder expires input model harness gates commit verdict reason question by expired].freeze
|
|
65
73
|
|
|
66
74
|
SEPARATOR = " "
|
|
67
75
|
|
|
@@ -187,6 +195,7 @@ module NodeLedger
|
|
|
187
195
|
timestamp, subject, rest = m.captures
|
|
188
196
|
state, remainder = rest.split(/\s+/, 2)
|
|
189
197
|
fields, comment = scan_fields(remainder.to_s)
|
|
198
|
+
fields = NodeInputCompatibility.fields(fields)
|
|
190
199
|
{ timestamp: timestamp, subject: subject, state: state, fields: fields, comment: comment, raw: raw }
|
|
191
200
|
end
|
|
192
201
|
|