@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.
Files changed (103) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. 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 folded section errors; :ok is the 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
- # Fold section findings into a frontmatter result hash (shared by validate and
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
@@ -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. The /tmp bridge is a
16
- # per-session CACHE of this state; on any disagreement the lock file wins (D2).
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 actual source of truth for
31
- # Bridge.skill_ref (bridge.rb requires lock.rb, never the reverse, so the
32
- # table lives here rather than pulling Bridge into this dependency-free file
33
- # just to render two characters). InstallerCore::DEFAULT_AGENTS carries the
34
- # same values per adapter as documented config (see ACTION_2); this constant
35
- # is not read from it at runtime, by the same reasoning bridge.rb/hook-*
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
- def install_timer(home:, script_path:, ruby: RbConfig.ruby, interval: TICK_SECONDS)
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, "com.plastic.meter-watch.plist")
149
- File.write(plist_path, plist(script_path, home, ruby, interval))
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(script_path, home, ruby, interval)
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>com.plastic.meter-watch</string>
171
+ <string>#{label}</string>
163
172
  <key>ProgramArguments</key>
164
173
  <array>
165
- <string>#{ruby}</string>
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>
@@ -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
- # (fold A9).
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 (fold: report's example).
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 (fold: "a node passes on a section it does not have").
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 "packet_wrapper"
13
+ require_relative "data_boundary"
14
14
  require_relative "atomic_write"
15
15
  require_relative "arm"
16
16
 
17
- # NodePacket (intent 338, G5): builds a node's whole input from disk, the
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 packet's
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 NodePacket
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
- # packet carries no lease and whenever the worktree Arm.worktree_block
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 `packet=<sha>` is a function of the repo's history alone
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 packet straight to exit 4 with no cut able to help.
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 packet disagree with each other. `build` reads once and threads the
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 packet renders no lease at all (matrix 2.11a, post-execution
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 packet's own trust
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 = PacketWrapper.estimate_tokens(text)
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 = PacketWrapper.estimate_tokens(text)
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 packet for that project, since
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 packet
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 packet it starts with, never from a denied call mid-edit
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 packet's identity
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 packet, node and where-to-work as plain instruction text, ledger,
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 `PacketWrapper.neutralize_marker_lines`
622
- # before it ever reaches the packet, so a forged marker cannot open a
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 render_packet(node_text:, ledger_text:, intent_text:, decisions:, insights:, hop:, where_text:,
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 = PacketWrapper.boundary_token(payloads)
633
+ token = DataBoundary.boundary_token(payloads)
634
634
 
635
- node_text_safe = PacketWrapper.neutralize_marker_lines(node_text.to_s)
636
- where_text_safe = PacketWrapper.neutralize_marker_lines(where_text.to_s)
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: PacketWrapper.attr_safe(LEDGER_LABEL), source: PacketWrapper.attr_safe("savepoint.md") },
645
- { label: PacketWrapper.attr_safe(RECORD_LABEL), source: PacketWrapper.attr_safe(record_source) },
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: PacketWrapper.attr_safe(HOP_LABEL), source: PacketWrapper.attr_safe(hop_source) } if hop_text
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 << PacketWrapper.wrap(ledger_text, label: LEDGER_LABEL, source: "savepoint.md", token: token).rstrip
650
+ parts << DataBoundary.wrap(ledger_text, label: LEDGER_LABEL, source: "savepoint.md", token: token).rstrip
651
651
  parts << ""
652
- parts << PacketWrapper.wrap(record_text, label: RECORD_LABEL, source: record_source, token: token).rstrip
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 << PacketWrapper.wrap(hop_text, label: HOP_LABEL, source: hop_source, token: token).rstrip
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
- assert_packet_integrity!(rendered, wrapped_specs)
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 packet must unwrap to exactly the data blocks that were
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 assert_packet_integrity!(rendered, wrapped_specs)
672
- actual = PacketWrapper.unwrap(rendered).map { |b| { label: b[:label], source: b[:source] } }
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 packet integrity check failed: expected #{wrapped_specs.inspect}, got #{actual.inspect}"
675
+ raise "node input integrity check failed: expected #{wrapped_specs.inspect}, got #{actual.inspect}"
676
676
  end
677
- private_class_method :assert_packet_integrity!
677
+ private_class_method :assert_input_integrity!
678
678
 
679
679
  def render_from_state(state)
680
- render_packet(node_text: state[:node_text], ledger_text: state[:ledger_text], intent_text: state[:intent_text],
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
- PacketWrapper.estimate_tokens(rendered)
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| PacketWrapper.estimate_tokens(t.to_s) }
752
- [name, PacketWrapper.estimate_tokens(text.to_s)]
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=/packet=/model=)
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 `packets/<node>--a<N>.extensions` names, since that
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 packet_path(intent_dir:, node:, attempt:)
775
- File.join(intent_dir, "packets", "#{node}--a#{attempt}.packet")
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 packet=#{sha} --field hop=#{hop_tokens}"
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 packet from disk (spec D1). Returns
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 packet disagree with each other.
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 packet carries no lease at all, never inside block 2's
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 = "packet for #{node} is #{tokens} tokens after every cut, over the #{budget_tokens}-token " \
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) : packet_path(intent_dir: intent_dir, node: node, attempt: attempt_n)
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 packet model],
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
- FIELD_ORDER = %w[holder expires packet model gates commit verdict reason question by expired].freeze
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