@zalom/plastic 2.0.0-alpha.23 → 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-node-research.md +4 -4
- package/agents/plastic-node-verify.md +4 -4
- package/agents/plastic-node-work.md +4 -4
- package/package.json +1 -1
- package/scripts/dashboard.rb +1 -1
- package/scripts/end-intent +19 -16
- package/scripts/exec-worktree +5 -5
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +15 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +2 -1
- package/scripts/hook-session-start +9 -10
- package/scripts/lib/active_delivery.rb +44 -0
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +2 -2
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/graph_measure_budget.rb +29 -28
- package/scripts/lib/handoff.rb +4 -8
- package/scripts/lib/harness_adapter.rb +6 -6
- package/scripts/lib/index_entry.rb +53 -0
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +120 -7
- package/scripts/lib/lock.rb +8 -9
- 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 +4 -2
- package/scripts/lib/outcome_report.rb +1 -1
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +5 -5
- package/scripts/lib/runner_dispatch.rb +44 -44
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +1 -1
- package/scripts/lib/savepoint.rb +6 -7
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +20 -14
- package/scripts/lib/session_ledger.rb +44 -4
- package/scripts/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +19 -19
- package/scripts/plastic-lock +33 -32
- package/scripts/runner +1 -1
- package/skills/auto/SKILL.md +7 -7
- 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 +1 -1
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +1 -1
- package/skills/intent-executing/SKILL.md +1 -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/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
|
@@ -6,27 +6,28 @@ require "json"
|
|
|
6
6
|
require "tmpdir"
|
|
7
7
|
require_relative "node_ledger"
|
|
8
8
|
require_relative "node_file"
|
|
9
|
-
require_relative "
|
|
10
|
-
require_relative "
|
|
9
|
+
require_relative "node_input"
|
|
10
|
+
require_relative "node_input_compatibility"
|
|
11
|
+
require_relative "data_boundary"
|
|
11
12
|
|
|
12
13
|
# GraphMeasureBudget (intent 343, G10, n4): whether the `budget:` a node's
|
|
13
14
|
# envelope declares (327 C19, D47) is a ceiling that ever actually held.
|
|
14
|
-
# Intent 340 found at 22:14Z on 2026-09-10 that every runner-built
|
|
15
|
+
# Intent 340 found at 22:14Z on 2026-09-10 that every runner-built node input
|
|
15
16
|
# was capped at 8000 tokens whatever the node declared (v1 major M7); this
|
|
16
17
|
# module is the measurement of whether that still holds, from the ledger
|
|
17
|
-
# and the real
|
|
18
|
+
# and the real input files alone.
|
|
18
19
|
#
|
|
19
20
|
# Read-only (spec D2): it opens `nodes/*.md`, `savepoint.md` and files under
|
|
20
|
-
# `
|
|
21
|
-
# `:unavailable`, never zero and never blank (spec D3); a
|
|
22
|
-
# by `
|
|
21
|
+
# `attempts/`, and writes nothing. A measure with no source prints
|
|
22
|
+
# `:unavailable`, never zero and never blank (spec D3); a input file named
|
|
23
|
+
# by `input=` that is not on disk is counted and named by its sha, never
|
|
23
24
|
# silently treated as zero bytes (spec D4).
|
|
24
25
|
#
|
|
25
26
|
# Adds no second parser and no second estimator: `NodeLedger` owns the
|
|
26
27
|
# transition-line format, `NodeFile` owns the envelope's `budget:`,
|
|
27
|
-
# `
|
|
28
|
-
# `
|
|
29
|
-
#
|
|
28
|
+
# `NodeInput` owns node input path resolution and attempt numbering, and
|
|
29
|
+
# `DataBoundary.estimate_tokens` is the one token formula, the same the
|
|
30
|
+
# input builder enforced with (spec D8).
|
|
30
31
|
module GraphMeasureBudget
|
|
31
32
|
module_function
|
|
32
33
|
|
|
@@ -35,7 +36,7 @@ module GraphMeasureBudget
|
|
|
35
36
|
|
|
36
37
|
# {ok:, nodes: {id => {declared_budget:, attempts: [...]}}, ceiling: {...}}.
|
|
37
38
|
# Never raises: a missing savepoint.md, a node with no envelope file, and a
|
|
38
|
-
#
|
|
39
|
+
# input= naming a file that does not exist on disk all resolve to
|
|
39
40
|
# `:unavailable` fields rather than an exception.
|
|
40
41
|
def read(intent_dir, node_reader: NodeFile.method(:parse))
|
|
41
42
|
dir = File.expand_path(intent_dir.to_s)
|
|
@@ -54,12 +55,12 @@ module GraphMeasureBudget
|
|
|
54
55
|
declared_budget = declared_budget_for(dir, subject, node_reader)
|
|
55
56
|
attempts = indices.map do |idx|
|
|
56
57
|
entry = entries[idx]
|
|
57
|
-
# Row 4.5: the attempt number is
|
|
58
|
+
# Row 4.5: the attempt number is NodeInput's own count of non-torn
|
|
58
59
|
# `running` lines up to and including this one, never
|
|
59
60
|
# ReadySet.attempts_count (which resets after `done`, `superseded`
|
|
60
61
|
# or `abandoned` and answers "attempts left", not "which attempt was
|
|
61
62
|
# this line").
|
|
62
|
-
attempt_number =
|
|
63
|
+
attempt_number = NodeInput.compute_attempt_number(
|
|
63
64
|
intent_dir: dir, node: subject, lease_flag_given: false, entries: entries[0..idx]
|
|
64
65
|
)
|
|
65
66
|
build_attempt(dir, subject, attempt_number, entry, declared_budget)
|
|
@@ -105,9 +106,9 @@ module GraphMeasureBudget
|
|
|
105
106
|
def attempt_model(a)
|
|
106
107
|
{
|
|
107
108
|
"attempt" => a[:attempt],
|
|
108
|
-
"
|
|
109
|
+
"input_sha_declared" => av(a[:input_sha_declared]),
|
|
109
110
|
"file_exists" => a[:file_exists],
|
|
110
|
-
"
|
|
111
|
+
"input_sha_actual" => av(a[:input_sha_actual]),
|
|
111
112
|
"sha_match" => a[:sha_match],
|
|
112
113
|
"bytes" => av(a[:bytes]),
|
|
113
114
|
"estimate_tokens" => av(a[:estimate_tokens]),
|
|
@@ -132,7 +133,7 @@ module GraphMeasureBudget
|
|
|
132
133
|
def node_block(nodes)
|
|
133
134
|
lines = ["== Budget =="]
|
|
134
135
|
if nodes.empty?
|
|
135
|
-
lines << "(no
|
|
136
|
+
lines << "(no node input attempts recorded)"
|
|
136
137
|
return lines
|
|
137
138
|
end
|
|
138
139
|
|
|
@@ -179,7 +180,7 @@ module GraphMeasureBudget
|
|
|
179
180
|
# n5's `GraphMeasureModels` already uses for its own `present?` and
|
|
180
181
|
# `resolve_kind`): a local copy, not a second parser or a reopen.
|
|
181
182
|
def declared_budget_for(dir, node, node_reader)
|
|
182
|
-
path =
|
|
183
|
+
path = NodeInput.find_node_path(dir, node)
|
|
183
184
|
return :unavailable unless path
|
|
184
185
|
|
|
185
186
|
parsed = with_safe_path(path) { |p| p ? node_reader.call(p) : nil }
|
|
@@ -209,22 +210,22 @@ module GraphMeasureBudget
|
|
|
209
210
|
|
|
210
211
|
def build_attempt(dir, subject, attempt_number, entry, declared_budget)
|
|
211
212
|
fields = entry[:fields] || {}
|
|
212
|
-
|
|
213
|
+
input_sha_declared = present?(fields["input"]) ? fields["input"] : :unavailable
|
|
213
214
|
# D8: hop= is written once, on the running line; absent means no hop
|
|
214
215
|
# block was appended (the feature predates this line, or hop is off),
|
|
215
216
|
# never an unknown quantity to subtract.
|
|
216
217
|
hop = present?(fields["hop"]) ? fields["hop"].to_i : 0
|
|
217
|
-
path =
|
|
218
|
+
path = NodeInputCompatibility.input_path(intent_dir: dir, node: subject, attempt: attempt_number)
|
|
218
219
|
file_exists = File.exist?(path)
|
|
219
220
|
|
|
220
221
|
if file_exists
|
|
221
222
|
raw = File.binread(path)
|
|
222
223
|
bytes = raw.bytesize
|
|
223
224
|
sha_actual = Digest::SHA256.hexdigest(raw)[0, 12]
|
|
224
|
-
estimate =
|
|
225
|
+
estimate = DataBoundary.estimate_tokens(raw)
|
|
225
226
|
effective = estimate - hop
|
|
226
227
|
over_budget = declared_budget.is_a?(Integer) ? effective > declared_budget : :unavailable
|
|
227
|
-
sha_match = sha_actual ==
|
|
228
|
+
sha_match = sha_actual == input_sha_declared
|
|
228
229
|
else
|
|
229
230
|
bytes = :unavailable
|
|
230
231
|
sha_actual = :unavailable
|
|
@@ -236,10 +237,10 @@ module GraphMeasureBudget
|
|
|
236
237
|
|
|
237
238
|
{
|
|
238
239
|
attempt: attempt_number,
|
|
239
|
-
|
|
240
|
-
|
|
240
|
+
input_sha_declared: input_sha_declared,
|
|
241
|
+
input_path: path,
|
|
241
242
|
file_exists: file_exists,
|
|
242
|
-
|
|
243
|
+
input_sha_actual: sha_actual,
|
|
243
244
|
sha_match: sha_match,
|
|
244
245
|
bytes: bytes,
|
|
245
246
|
estimate_tokens: estimate,
|
|
@@ -258,13 +259,13 @@ module GraphMeasureBudget
|
|
|
258
259
|
|
|
259
260
|
# --- the suspected ceiling: spec matrix rows 4.8, 4.9 -----------------------
|
|
260
261
|
|
|
261
|
-
# A run of
|
|
262
|
+
# A run of node inputs whose estimates all fall under one value well below
|
|
262
263
|
# their own declared budgets (row 4.8): the candidate ceiling is the
|
|
263
264
|
# largest effective estimate among the usable attempts (every attempt
|
|
264
|
-
# whose declared budget and
|
|
265
|
+
# whose declared budget and input file are both known), and it is only
|
|
265
266
|
# reported when that value sits strictly under EVERY one of those
|
|
266
267
|
# attempts' own declared budgets - never a "cluster within a few percent"
|
|
267
|
-
# of each other, which never fires against a real spread of
|
|
268
|
+
# of each other, which never fires against a real spread of node input sizes.
|
|
268
269
|
# Row 4.9: fewer than two distinct nodes never reports a ceiling; one
|
|
269
270
|
# node's own repeated attempts are not evidence of a ceiling shared across
|
|
270
271
|
# the intent.
|
|
@@ -280,7 +281,7 @@ module GraphMeasureBudget
|
|
|
280
281
|
# "Well below" needs a stated threshold: WELL_BELOW_RATIO is that
|
|
281
282
|
# threshold, and a ceiling is reported only when the candidate uses no
|
|
282
283
|
# more than this fraction of EVERY usable attempt's own declared budget.
|
|
283
|
-
# Reproduced against 340's real
|
|
284
|
+
# Reproduced against 340's real node inputs: declared budgets there run from
|
|
284
285
|
# 50000 to 160000 tokens (327 D47's per-kind defaults), and every
|
|
285
286
|
# attempt's effective token count clusters between 3380 and 7717 - the
|
|
286
287
|
# candidate (7717) is at most 7717/50000 = 15.4% of even the SMALLEST
|
package/scripts/lib/handoff.rb
CHANGED
|
@@ -38,15 +38,11 @@ module Handoff
|
|
|
38
38
|
File.join(SessionLedger.day_dir(store, day), "handoff--#{session}.md")
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
# The day this session's hand-off belongs to: the
|
|
42
|
-
#
|
|
43
|
-
#
|
|
41
|
+
# The day this session's hand-off belongs to: the session's day from the
|
|
42
|
+
# day ledger (SessionLedger.session_day, graph.md D7), the same lookup
|
|
43
|
+
# SessionClose uses for the drop at close.
|
|
44
44
|
def day_for(store, session, today:)
|
|
45
|
-
|
|
46
|
-
return today unless File.exist?(path)
|
|
47
|
-
|
|
48
|
-
value = File.read(path).strip
|
|
49
|
-
SessionLedger.valid_day_id?(value) ? value : today
|
|
45
|
+
SessionLedger.session_day(store, session, today: today)
|
|
50
46
|
end
|
|
51
47
|
|
|
52
48
|
def clip(summary)
|
|
@@ -64,7 +64,7 @@ module HarnessAdapter
|
|
|
64
64
|
# squashed (whitespace collapsed, matrix row 1.18) before it is even
|
|
65
65
|
# compared, so a config-authored value carrying a tab or a newline can
|
|
66
66
|
# never reach `NodeLedger` unsquashed and raise `ArgumentError` mid-dispatch,
|
|
67
|
-
# after the
|
|
67
|
+
# after the node input is already built. A value that still is not one of
|
|
68
68
|
# KNOWN_KEYS after squashing falls back to DEFAULT_KEY with a warning
|
|
69
69
|
# (never to no adapter), so this method's return is always one of the two
|
|
70
70
|
# known, clean strings - safe to hand straight to a ledger field.
|
|
@@ -101,7 +101,7 @@ module HarnessAdapter
|
|
|
101
101
|
# list (matrix row 1.14: a step that dispatched nothing prints no
|
|
102
102
|
# instruction block for a session to act on). `dispatched` is the runner's
|
|
103
103
|
# own plan entries (RunnerDispatch's `:dispatched` shape): [{node:, kind:,
|
|
104
|
-
# role:, model:, worktree:,
|
|
104
|
+
# role:, model:, worktree:, input:}, ...]. The model and the node input path
|
|
105
105
|
# ride straight from each entry - never a fresh lookup (matrix row 1.11)
|
|
106
106
|
# and never a summary (matrix row 1.12) - and `return_contract` (the exact
|
|
107
107
|
# text RunnerDispatch::RETURN_CONTRACT already carries in the YAML plan)
|
|
@@ -115,11 +115,11 @@ module HarnessAdapter
|
|
|
115
115
|
end
|
|
116
116
|
|
|
117
117
|
# The Claude Code rendering (spec Approach): per dispatched node, the agent
|
|
118
|
-
# type from the kind, the model the plan already resolved, and the
|
|
118
|
+
# type from the kind, the model the plan already resolved, and the node input
|
|
119
119
|
# path as the whole prompt.
|
|
120
120
|
def render_claude_code(entries, return_contract)
|
|
121
121
|
blocks = entries.map do |d|
|
|
122
|
-
"Dispatch #{agent_type_for_kind(d[:kind])} for #{d[:node]} (model: #{d[:model]}):\n prompt: #{d[:
|
|
122
|
+
"Dispatch #{agent_type_for_kind(d[:kind])} for #{d[:node]} (model: #{d[:model]}):\n prompt: #{d[:input]}"
|
|
123
123
|
end
|
|
124
124
|
"#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
|
|
125
125
|
end
|
|
@@ -128,13 +128,13 @@ module HarnessAdapter
|
|
|
128
128
|
# The Codex rendering: a Codex node runs end to end through `scripts/
|
|
129
129
|
# node-run` (n6), which reads the ledger's own `running` line directly and
|
|
130
130
|
# never through this printed block - so this names, per node, the same
|
|
131
|
-
# facts the block gives Claude Code (kind, model,
|
|
131
|
+
# facts the block gives Claude Code (kind, model, node input path) framed as
|
|
132
132
|
# `node-run` calls, proving the seam renders SOMETHING for the second
|
|
133
133
|
# harness rather than nothing (matrix row 1.27), without inventing detail
|
|
134
134
|
# that belongs to n6's own adapter.
|
|
135
135
|
def render_codex(entries, return_contract)
|
|
136
136
|
blocks = entries.map do |d|
|
|
137
|
-
"node-run #{d[:node]} (#{d[:kind]}, model: #{d[:model]}):\n
|
|
137
|
+
"node-run #{d[:node]} (#{d[:kind]}, model: #{d[:model]}):\n input: #{d[:input]}"
|
|
138
138
|
end
|
|
139
139
|
"#{return_contract.to_s.strip}\n\n#{blocks.join("\n\n")}\n"
|
|
140
140
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# IndexEntry - the shared INDEX.md entry matcher and Active check (intent 188,
|
|
5
|
+
# D12/D13; moved off the retired shared-helpers module by intent 344, D2).
|
|
6
|
+
#
|
|
7
|
+
# ONE definition site for the "- [ID <sep> Title](link)" shape both `active?`
|
|
8
|
+
# (below) and `scripts/end-intent`'s own INDEX-move parser depend on, so the
|
|
9
|
+
# two regexes can never drift apart. Accepts a real em dash (U+2014) OR a
|
|
10
|
+
# plain hyphen as the id/title separator on READ; every WRITE still emits the
|
|
11
|
+
# real em dash (D10). The separator is built from the codepoint, not a
|
|
12
|
+
# literal byte, so this file stays em-dash free.
|
|
13
|
+
module IndexEntry
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
EM_DASH = "\u2014".freeze
|
|
17
|
+
ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
|
|
18
|
+
|
|
19
|
+
# Match `line` (already chomped) against the shared INDEX entry shape.
|
|
20
|
+
# Returns a MatchData (captures: 1 = id, 2 = title, 3 = link) or nil.
|
|
21
|
+
def match(line)
|
|
22
|
+
line.to_s.match(ENTRY_RE)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# True iff the intent is Active in its store's INDEX.md, which lives at the
|
|
26
|
+
# PARENT of the store/ dir. Non-raising: any failure (missing or unreadable
|
|
27
|
+
# INDEX, bad arg) returns false. `index_active_ids` is a pure-data test
|
|
28
|
+
# seam: when an Array of id strings is supplied, membership is checked
|
|
29
|
+
# against it directly with no file read.
|
|
30
|
+
def active?(intent_id, store:, index_active_ids: nil)
|
|
31
|
+
target = intent_id.to_s
|
|
32
|
+
return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
index = File.join(File.dirname(store.to_s), "INDEX.md")
|
|
35
|
+
return false unless File.exist?(index)
|
|
36
|
+
|
|
37
|
+
in_active = false
|
|
38
|
+
File.foreach(index) do |line|
|
|
39
|
+
stripped = line.chomp
|
|
40
|
+
if stripped == "## Active"
|
|
41
|
+
in_active = true
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
next unless in_active
|
|
45
|
+
break if stripped.start_with?("## ") # next section ends the Active block
|
|
46
|
+
m = match(stripped)
|
|
47
|
+
return true if m && m[1] == target
|
|
48
|
+
end
|
|
49
|
+
false
|
|
50
|
+
rescue StandardError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
end
|
package/scripts/lib/insights.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
|
|
4
4
|
# insights.rb - the blessed write path for an intent's `## Insights` section
|
|
5
|
-
# (intent 82). A
|
|
5
|
+
# (intent 82). A standalone library so the insight write path stays
|
|
6
6
|
# independent of the savepoint ledger (the spec forbids touching the ledger).
|
|
7
7
|
#
|
|
8
8
|
# Every entry leads with a fixed, machine-parseable prefix
|
|
@@ -6,6 +6,7 @@ require "yaml"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require "digest"
|
|
8
8
|
require "time"
|
|
9
|
+
require "tmpdir"
|
|
9
10
|
require_relative "hook_registry"
|
|
10
11
|
require_relative "agent_models"
|
|
11
12
|
require_relative "harness_text"
|
|
@@ -254,7 +255,10 @@ class InstallerCore
|
|
|
254
255
|
|
|
255
256
|
# --- Distribution phase ---
|
|
256
257
|
|
|
257
|
-
|
|
258
|
+
# `tmp_dirs:` defaults to the real system tmp directory plus `/tmp` (where a
|
|
259
|
+
# retired session-state leftover would sit); a test overrides it (often to
|
|
260
|
+
# `[]`) so the removal never scans the real filesystem's tmp directories.
|
|
261
|
+
def distribute(mode, tmp_dirs: [Dir.tmpdir, "/tmp"].uniq)
|
|
258
262
|
puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{plastic_home}"
|
|
259
263
|
|
|
260
264
|
manifest_path = File.join(plastic_home, "manifest.json")
|
|
@@ -296,6 +300,8 @@ class InstallerCore
|
|
|
296
300
|
write_manifest(global_files, manifest_path)
|
|
297
301
|
|
|
298
302
|
pruned = prune_removed_files(old_files - global_files, root: plastic_home)
|
|
303
|
+
removed = remove_retired_session_state(plastic_home: plastic_home, tmp_dirs: tmp_dirs)
|
|
304
|
+
puts " \u{1f9f9} #{removed} retired session state file(s) removed" if removed.positive?
|
|
299
305
|
|
|
300
306
|
puts " \u{2705} Core files synced (v#{version})#{pruned.positive? ? ", #{pruned} stale file(s) pruned" : ""}"
|
|
301
307
|
end
|
|
@@ -372,10 +378,12 @@ class InstallerCore
|
|
|
372
378
|
"scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
|
|
373
379
|
"scripts/lib/config_asks.rb" => "scripts/lib/config_asks.rb",
|
|
374
380
|
"scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
|
|
375
|
-
"scripts/lib/
|
|
381
|
+
"scripts/lib/index_entry.rb" => "scripts/lib/index_entry.rb",
|
|
382
|
+
"scripts/lib/project_config.rb" => "scripts/lib/project_config.rb",
|
|
376
383
|
"scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
|
|
377
384
|
"scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
|
|
378
385
|
"scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
|
|
386
|
+
"scripts/lib/node_input_compatibility.rb" => "scripts/lib/node_input_compatibility.rb",
|
|
379
387
|
"scripts/lib/node_progress.rb" => "scripts/lib/node_progress.rb",
|
|
380
388
|
"scripts/node-transition" => "scripts/node-transition",
|
|
381
389
|
"scripts/lib/arm.rb" => "scripts/lib/arm.rb",
|
|
@@ -513,11 +521,11 @@ class InstallerCore
|
|
|
513
521
|
"scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
|
|
514
522
|
"scripts/roadmap-graph" => "scripts/roadmap-graph",
|
|
515
523
|
"scripts/index-projection" => "scripts/index-projection",
|
|
516
|
-
# Intent 338 (G5): the node
|
|
524
|
+
# Intent 338 (G5): the node input command - the trust-boundary wrapper,
|
|
517
525
|
# the five-block gatherer/assembler, and the CLI 340's runner calls.
|
|
518
|
-
"scripts/lib/
|
|
519
|
-
"scripts/lib/
|
|
520
|
-
"scripts/node-
|
|
526
|
+
"scripts/lib/data_boundary.rb" => "scripts/lib/data_boundary.rb",
|
|
527
|
+
"scripts/lib/node_input.rb" => "scripts/lib/node_input.rb",
|
|
528
|
+
"scripts/node-input" => "scripts/node-input",
|
|
521
529
|
# Intent 342 (G9): the backward shim that presents actions/*.md as a
|
|
522
530
|
# node graph for any legacy intent, so WorkGraphValidator can require
|
|
523
531
|
# it without going red on contact with install_sync_test.
|
|
@@ -593,7 +601,7 @@ class InstallerCore
|
|
|
593
601
|
"scripts/lib/graph_measure_report.rb" => "scripts/lib/graph_measure_report.rb",
|
|
594
602
|
# Intent 343 (G10, n4): the `budget` verb - whether the `budget:` a
|
|
595
603
|
# node's envelope declares (327 C19) is a ceiling that ever actually
|
|
596
|
-
# held, read from the ledger and the real
|
|
604
|
+
# held, read from the ledger and the real input files.
|
|
597
605
|
"scripts/lib/graph_measure_budget.rb" => "scripts/lib/graph_measure_budget.rb",
|
|
598
606
|
# Intent 343 (G10, n5): the `cohorts` verb's model section - the store
|
|
599
607
|
# walk and whether a recorded model= still matches what config
|
|
@@ -885,6 +893,111 @@ class InstallerCore
|
|
|
885
893
|
removed
|
|
886
894
|
end
|
|
887
895
|
|
|
896
|
+
# Intent 344 (G11, D10): leftovers from the retired per-session day pointer
|
|
897
|
+
# and the retired inter-hook coordination key. A `current` candidate is
|
|
898
|
+
# real only when reached with no symlink between plastic_home and the
|
|
899
|
+
# session directory: a store root's own real path must equal the plain
|
|
900
|
+
# join of plastic_home's real path with the store's lexical relative path
|
|
901
|
+
# (a symlinked store, or a symlinked projects/<slug> above it, breaks
|
|
902
|
+
# that equality), and neither the store's `.tmp` directory nor the
|
|
903
|
+
# session directory beneath it may itself be a symlink. A tmp-file
|
|
904
|
+
# candidate is judged by its containing directory's real path instead,
|
|
905
|
+
# so a symlinked tmp root such as macOS's real /tmp still works; its
|
|
906
|
+
# name is matched on raw bytes so an undecodable name never raises.
|
|
907
|
+
# Never opens or parses a candidate: a torn or malformed leftover is
|
|
908
|
+
# deleted by name alone, exactly like a well-formed one. Only a regular
|
|
909
|
+
# file that is not a symlink is ever a candidate for deletion. Returns
|
|
910
|
+
# the count removed; a delete failure warns once per path and the sync
|
|
911
|
+
# continues, since a stuck leftover must never fail an install.
|
|
912
|
+
def remove_retired_session_state(plastic_home:, tmp_dirs:)
|
|
913
|
+
candidates = []
|
|
914
|
+
|
|
915
|
+
real_store_roots(plastic_home).each do |store_root|
|
|
916
|
+
candidates.concat(current_candidates(store_root))
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
Array(tmp_dirs).compact.each do |dir|
|
|
920
|
+
candidates.concat(tmp_json_candidates(dir))
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
removed = 0
|
|
924
|
+
candidates.uniq.each do |candidate|
|
|
925
|
+
begin
|
|
926
|
+
next unless File.lstat(candidate).file?
|
|
927
|
+
rescue SystemCallError
|
|
928
|
+
next
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
begin
|
|
932
|
+
File.delete(candidate)
|
|
933
|
+
removed += 1
|
|
934
|
+
rescue SystemCallError => e
|
|
935
|
+
warn " \u{26a0}\u{fe0f} Could not remove retired session state #{candidate}: #{e.message}"
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
removed
|
|
939
|
+
rescue StandardError
|
|
940
|
+
removed || 0
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# Every store root (the global store, plus each project store) whose real
|
|
944
|
+
# path is reached with no symlink between it and plastic_home.
|
|
945
|
+
def real_store_roots(plastic_home)
|
|
946
|
+
roots = [File.join(plastic_home, "store")] +
|
|
947
|
+
Dir.glob(File.join(plastic_home, "projects", "*", "store"))
|
|
948
|
+
roots.select { |root| real_store_root?(plastic_home, root) }
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def real_store_root?(plastic_home, store_root)
|
|
952
|
+
return false unless File.directory?(store_root)
|
|
953
|
+
|
|
954
|
+
real_home = File.realpath(plastic_home)
|
|
955
|
+
relative = store_root.sub(/\A#{Regexp.escape(plastic_home)}\/?/, "")
|
|
956
|
+
File.realpath(store_root) == File.join(real_home, relative)
|
|
957
|
+
rescue SystemCallError
|
|
958
|
+
false
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
# `current` candidates under one validated store root: `.tmp` and every
|
|
962
|
+
# session directory beneath it must be real directories, never symlinks.
|
|
963
|
+
def current_candidates(store_root)
|
|
964
|
+
tmp_root = File.join(store_root, ".tmp")
|
|
965
|
+
return [] unless File.lstat(tmp_root).directory?
|
|
966
|
+
|
|
967
|
+
Dir.children(tmp_root).filter_map do |child|
|
|
968
|
+
begin
|
|
969
|
+
session_dir = File.join(tmp_root, child)
|
|
970
|
+
next unless File.lstat(session_dir).directory?
|
|
971
|
+
|
|
972
|
+
File.join(session_dir, "current")
|
|
973
|
+
rescue SystemCallError
|
|
974
|
+
nil
|
|
975
|
+
end
|
|
976
|
+
end
|
|
977
|
+
rescue SystemCallError
|
|
978
|
+
[]
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
# Retired session-state leftover files sitting directly in `dir`, resolved through
|
|
982
|
+
# `dir`'s own real path so a symlinked tmp root (macOS's real /tmp) still
|
|
983
|
+
# works; the name match runs on raw bytes so an undecodable name is
|
|
984
|
+
# simply not a match, never a raised error.
|
|
985
|
+
def tmp_json_candidates(dir)
|
|
986
|
+
real = File.realpath(dir)
|
|
987
|
+
Dir.children(real).select { |name| retired_tmp_name?(name) }.map { |name| File.join(real, name) }
|
|
988
|
+
rescue SystemCallError
|
|
989
|
+
[]
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# True when `name`'s raw bytes match the retired session-state leftover shape
|
|
993
|
+
# (`plastic-<session>--<id>.json`). Runs on `name.b` so a name that is
|
|
994
|
+
# not valid UTF-8 is judged on bytes instead of raising.
|
|
995
|
+
def retired_tmp_name?(name)
|
|
996
|
+
name.b.match?(/\Aplastic-[^\/]+--[^\/]+\.json\z/n)
|
|
997
|
+
rescue StandardError
|
|
998
|
+
false
|
|
999
|
+
end
|
|
1000
|
+
|
|
888
1001
|
# True when `path`, once expanded, is one of `roots` itself or lives underneath
|
|
889
1002
|
# one of them. String-prefix containment guarded by a trailing separator so a
|
|
890
1003
|
# sibling directory that merely shares a prefix (`/home/x-evil` vs root `/home/x`)
|
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
|