@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20
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/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +886 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +392 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +24 -17
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +17 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
# NodeFile (intent 334, n3): one node file's YAML envelope (`node`, `kind`,
|
|
8
|
+
# `files`, `budget`) over a Markdown body, plus the deterministic id minter
|
|
9
|
+
# (327 D1r, D5r, D9r-D12r, D16r). The kind-prefix rule lives here, not in
|
|
10
|
+
# GraphEdges, which stays loose about id grammar so a numeric roadmap id
|
|
11
|
+
# parses the same way (D12r).
|
|
12
|
+
#
|
|
13
|
+
# Pure and side-effect-free: parse reads one file and returns a Result hash,
|
|
14
|
+
# never raising across the boundary; mint_id takes the ids already present
|
|
15
|
+
# and returns one past the highest of them. Reserving an id against a
|
|
16
|
+
# concurrent minter is G7's problem, not a claim this module makes (D16r);
|
|
17
|
+
# gathering the ids an intent has ever seen is NodeIds' job (335a), which is
|
|
18
|
+
# why this module still requires nothing but yaml and date.
|
|
19
|
+
module NodeFile
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
KIND_PREFIX = { "work" => "n", "verify" => "v", "decision" => "d", "research" => "r" }.freeze
|
|
23
|
+
VALID_KINDS = KIND_PREFIX.keys.freeze
|
|
24
|
+
FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
|
|
25
|
+
|
|
26
|
+
# {ok:, node:, kind:, files:, budget:, body:, errors:}. `body` is the raw
|
|
27
|
+
# text after the frontmatter block, present even when `ok` is false and the
|
|
28
|
+
# body itself parsed fine, so a caller can still inspect sections on an
|
|
29
|
+
# envelope-invalid file. Every failure mode returns errors rather than
|
|
30
|
+
# raising: a missing file, absent or non-mapping frontmatter, or YAML that
|
|
31
|
+
# will not parse.
|
|
32
|
+
def parse(path)
|
|
33
|
+
return failure(["node file not found: #{path}"]) unless File.exist?(path)
|
|
34
|
+
|
|
35
|
+
content = File.read(path)
|
|
36
|
+
return failure(["missing YAML frontmatter"]) unless content.start_with?("---")
|
|
37
|
+
|
|
38
|
+
parts = content.split("---", 3)
|
|
39
|
+
return failure(["missing YAML frontmatter"]) if parts.length < 3
|
|
40
|
+
|
|
41
|
+
fm = begin
|
|
42
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time])
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
return failure(["frontmatter is not valid YAML: #{e.message}"])
|
|
45
|
+
end
|
|
46
|
+
fm = {} if fm.nil?
|
|
47
|
+
return failure(["frontmatter must be a mapping, got #{fm.class}"]) unless fm.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
body = parts[2].to_s
|
|
50
|
+
errors = []
|
|
51
|
+
|
|
52
|
+
kind = fm["kind"].to_s
|
|
53
|
+
unless VALID_KINDS.include?(kind)
|
|
54
|
+
errors << "unknown kind: #{fm["kind"].inspect} (must be one of #{VALID_KINDS.join(', ')})"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
id = fm["node"].to_s
|
|
58
|
+
errors << "missing node id (node:)" if id.empty?
|
|
59
|
+
|
|
60
|
+
if !id.empty?
|
|
61
|
+
basename = File.basename(path, ".md")
|
|
62
|
+
unless filename_matches_id?(basename, id)
|
|
63
|
+
errors << "node id #{id.inspect} does not match filename #{File.basename(path).inspect}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if !id.empty? && VALID_KINDS.include?(kind)
|
|
68
|
+
expected_prefix = KIND_PREFIX[kind]
|
|
69
|
+
unless id.match?(/\A#{Regexp.escape(expected_prefix)}[1-9][0-9]*\z/)
|
|
70
|
+
errors << "node id #{id.inspect} does not match kind #{kind.inspect}'s prefix #{expected_prefix.inspect}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
errors << "depends_on: is not a valid envelope field (removed by 327 D41; edges belong in ## Graph)" if fm.key?("depends_on")
|
|
75
|
+
errors << "turns: is not a valid envelope field (removed by 327 D47)" if fm.key?("turns")
|
|
76
|
+
|
|
77
|
+
files = fm["files"]
|
|
78
|
+
if files.nil?
|
|
79
|
+
errors << "missing files:"
|
|
80
|
+
elsif !files.is_a?(Array)
|
|
81
|
+
errors << "files: must be a list, got #{files.class}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
budget, budget_errors = normalize_budget(fm["budget"])
|
|
85
|
+
errors.concat(budget_errors)
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
ok: errors.empty?,
|
|
89
|
+
node: id.empty? ? nil : id,
|
|
90
|
+
kind: kind.empty? ? nil : kind,
|
|
91
|
+
files: files.is_a?(Array) ? files : nil,
|
|
92
|
+
budget: budget,
|
|
93
|
+
body: body,
|
|
94
|
+
errors: errors,
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def failure(errors)
|
|
99
|
+
{ ok: false, node: nil, kind: nil, files: nil, budget: nil, body: nil, errors: errors }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# <id>.md or <id>--<slug>.md, exactly - a longer id's file (n11--x.md) must
|
|
103
|
+
# never satisfy a shorter id (n1), so the id is matched as the whole prefix
|
|
104
|
+
# up to end-of-string or the literal "--" separator, never as a substring
|
|
105
|
+
# (fold A9).
|
|
106
|
+
def filename_matches_id?(basename, id)
|
|
107
|
+
basename.match?(/\A#{Regexp.escape(id)}(--[A-Za-z0-9][A-Za-z0-9-]*)?\z/)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# budget: normalizes to a plain integer token ceiling. A bare integer and
|
|
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).
|
|
113
|
+
def normalize_budget(raw)
|
|
114
|
+
return [nil, []] if raw.nil?
|
|
115
|
+
return [raw, []] if raw.is_a?(Integer)
|
|
116
|
+
|
|
117
|
+
if raw.is_a?(Hash)
|
|
118
|
+
return [nil, ["budget: {turns:} was removed by 327 D47; use budget: <int> or {tokens: <int>}"]] if raw.key?("turns") || raw.key?(:turns)
|
|
119
|
+
|
|
120
|
+
tokens = raw["tokens"] || raw[:tokens]
|
|
121
|
+
return [tokens, []] if tokens.is_a?(Integer)
|
|
122
|
+
|
|
123
|
+
return [nil, ["budget: must normalize to an integer token count, got #{raw.inspect}"]]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
[nil, ["budget: must be an integer or {tokens: <int>}, got #{raw.class}"]]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Every [heading_line, body] pair in the body text, split on any heading
|
|
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").
|
|
132
|
+
def split_by_headings(text)
|
|
133
|
+
sections = []
|
|
134
|
+
heading = nil
|
|
135
|
+
body = +""
|
|
136
|
+
each_fence_line(text) do |line, fenced|
|
|
137
|
+
if !fenced && line.start_with?("#")
|
|
138
|
+
sections << [heading, body] if heading
|
|
139
|
+
heading = line.strip
|
|
140
|
+
body = +""
|
|
141
|
+
else
|
|
142
|
+
body << line
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
sections << [heading, body] if heading
|
|
146
|
+
sections
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Markdown table data rows (header + separator stripped) in text, fence-aware.
|
|
150
|
+
def table_rows(text)
|
|
151
|
+
lines = []
|
|
152
|
+
each_fence_line(text) do |line, fenced|
|
|
153
|
+
next if fenced
|
|
154
|
+
|
|
155
|
+
stripped = line.strip
|
|
156
|
+
lines << stripped if stripped.start_with?("|")
|
|
157
|
+
end
|
|
158
|
+
sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
|
|
159
|
+
return [] unless sep_idx
|
|
160
|
+
|
|
161
|
+
lines[(sep_idx + 1)..].map { |l| l.split("|", -1).map(&:strip)[1..-2].to_a }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def each_fence_line(text)
|
|
165
|
+
return enum_for(:each_fence_line, text) unless block_given?
|
|
166
|
+
|
|
167
|
+
marker = nil
|
|
168
|
+
text.to_s.each_line do |line|
|
|
169
|
+
if marker
|
|
170
|
+
yield line, true
|
|
171
|
+
m = line.match(FENCE_LINE_RE)
|
|
172
|
+
next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
|
|
173
|
+
next unless line.sub(FENCE_LINE_RE, "").strip.empty?
|
|
174
|
+
|
|
175
|
+
marker = nil
|
|
176
|
+
else
|
|
177
|
+
m = line.match(FENCE_LINE_RE)
|
|
178
|
+
if m
|
|
179
|
+
marker = [m[1][0], m[1].length]
|
|
180
|
+
yield line, true
|
|
181
|
+
else
|
|
182
|
+
yield line, false
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# One past the highest id ever seen for the kind: purely numeric, so ids
|
|
189
|
+
# never sort as strings ("n10" ranking above "n9"), and a gap in the
|
|
190
|
+
# sequence STAYS a gap.
|
|
191
|
+
#
|
|
192
|
+
# Node ids never recycle (335a, owner ruling 2026-09-09). Intent 335 keyed
|
|
193
|
+
# the work ledger in savepoint.md on node id, and deletion is not a
|
|
194
|
+
# transition, so nothing in the ledger says a node is gone. Reissuing a
|
|
195
|
+
# deleted node's id therefore hands the new node the dead one's whole
|
|
196
|
+
# history: its `done` line, its evidence, its holder, and the successors
|
|
197
|
+
# that line released. The failure is silent, which is the worst shape it
|
|
198
|
+
# could take, so the gap is a headstone rather than free space.
|
|
199
|
+
#
|
|
200
|
+
# `taken` may carry ids this kind's grammar rejects, because NodeIds
|
|
201
|
+
# over-reserves on purpose (335a D11); they are ignored without shifting
|
|
202
|
+
# the sequence.
|
|
203
|
+
def mint_id(kind, taken)
|
|
204
|
+
prefix = KIND_PREFIX[kind.to_s]
|
|
205
|
+
return nil unless prefix
|
|
206
|
+
|
|
207
|
+
used = taken.to_a.filter_map do |id|
|
|
208
|
+
m = id.to_s.match(/\A#{Regexp.escape(prefix)}([1-9][0-9]*)\z/)
|
|
209
|
+
m && m[1].to_i
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
"#{prefix}#{(used.max || 0) + 1}"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "node_file"
|
|
5
|
+
require_relative "graph_file"
|
|
6
|
+
require_relative "graph_edges"
|
|
7
|
+
require_relative "node_ledger"
|
|
8
|
+
# Savepoint::INTENT_SUBJECT is named directly below, so the require is direct
|
|
9
|
+
# too: test/savepoint_split_test.rb:157 refuses a transitive one.
|
|
10
|
+
require_relative "savepoint"
|
|
11
|
+
|
|
12
|
+
# NodeIds (intent 335a): every node id one intent has EVER seen, gathered from
|
|
13
|
+
# the three places an id can appear, so NodeFile.mint_id can mint one past the
|
|
14
|
+
# highest and never reissue a deleted node's id.
|
|
15
|
+
#
|
|
16
|
+
# Why this exists as its own module rather than inside NodeFile: NodeFile is
|
|
17
|
+
# 334's deliberately dependency-free envelope reader (yaml and date only), and
|
|
18
|
+
# gathering needs the graph parsers and the ledger. It is not inside
|
|
19
|
+
# WorkGraphValidator either, because that module reports errors and minting is
|
|
20
|
+
# not validation.
|
|
21
|
+
#
|
|
22
|
+
# The rule this serves (335a D1, owner ruling 2026-09-09): a removed node still
|
|
23
|
+
# reserves its id. Deletion is not a ledger transition, so a reissued id would
|
|
24
|
+
# silently inherit the dead node's transition history, evidence included.
|
|
25
|
+
module NodeIds
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Every id seen in `intent_dir`, sorted and deduplicated. Each of the three
|
|
29
|
+
# sources is guarded on its own (D5): an intent that has not started yet is
|
|
30
|
+
# the ordinary case, not an error.
|
|
31
|
+
#
|
|
32
|
+
# The result deliberately OVER-reserves (D11). An id that no kind's grammar
|
|
33
|
+
# matches costs nothing, because mint_id filters by prefix; a missed id costs
|
|
34
|
+
# a false history.
|
|
35
|
+
def taken(intent_dir)
|
|
36
|
+
return [] unless intent_dir && File.directory?(intent_dir)
|
|
37
|
+
|
|
38
|
+
(from_node_files(intent_dir) + from_graph(intent_dir) + from_ledger(intent_dir))
|
|
39
|
+
.reject { |id| id.nil? || id.empty? }
|
|
40
|
+
.uniq
|
|
41
|
+
.sort
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Both the filename's id AND the envelope's `node:` (D7). A file can disagree
|
|
45
|
+
# with its own name, and a file whose frontmatter will not parse yields no
|
|
46
|
+
# envelope id at all, which is exactly when the filename is the only thing
|
|
47
|
+
# standing between a corrupt node file and a reissued id.
|
|
48
|
+
#
|
|
49
|
+
# Plain glob (D8): a zero-byte or sentinel-marked node file still names a node
|
|
50
|
+
# whose id is taken. Savepoint.has_real_files_in?'s filter answers a different
|
|
51
|
+
# question (has this intent started) and is not reused here.
|
|
52
|
+
def from_node_files(intent_dir)
|
|
53
|
+
Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort.flat_map do |path|
|
|
54
|
+
[id_from_filename(path), NodeFile.parse(path)[:node]]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The id a node filename declares: the basename up to the "--" separator, so
|
|
59
|
+
# both shapes 334 D11r ratifies reserve - the bare `n2.md` and the slugged
|
|
60
|
+
# `n1--graph-edges.md` (D6). Taking the whole basename would reserve nothing
|
|
61
|
+
# for every slugged file in the store, which is the dominant shape.
|
|
62
|
+
def id_from_filename(path)
|
|
63
|
+
File.basename(path, ".md").split("--", 2).first.to_s
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Declared plus targeted, from the `## Graph` section only (D9). The gatherer
|
|
67
|
+
# inherits GraphEdges' looseness: it is not fence-aware, so an id inside a
|
|
68
|
+
# fenced example is gathered. That over-reserves, which is the safe direction.
|
|
69
|
+
#
|
|
70
|
+
# `## Status` is NOT read (D10): it is a projection rendered from the ledger
|
|
71
|
+
# and rewritten wholesale, so every id it can carry the ledger already carries.
|
|
72
|
+
def from_graph(intent_dir)
|
|
73
|
+
path = File.join(intent_dir, "graph.md")
|
|
74
|
+
return [] unless File.exist?(path)
|
|
75
|
+
|
|
76
|
+
section = GraphFile.section_body(File.read(path).scrub, "## Graph")
|
|
77
|
+
return [] unless section
|
|
78
|
+
|
|
79
|
+
GraphEdges.parse(section)[:nodes].to_a
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Subjects only, read through NodeLedger.entries, NEVER a token scan (D4).
|
|
83
|
+
# A node id can sit inside an ordinary milestone's free text and does today:
|
|
84
|
+
# intent 335's own ledger carries "ACTION_1 v2" inside a Review line, and 23
|
|
85
|
+
# such tokens exist across the live store. NodeLedger.entries already owns the
|
|
86
|
+
# two-space split and already keeps a torn line's subject, so this reuses that
|
|
87
|
+
# seam rather than making a third copy of it.
|
|
88
|
+
#
|
|
89
|
+
# The literal `Intent` subject is dropped: it is an intent-scope line, not a
|
|
90
|
+
# node, and no kind's grammar would match it anyway.
|
|
91
|
+
def from_ledger(intent_dir)
|
|
92
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
93
|
+
return [] unless File.exist?(path)
|
|
94
|
+
|
|
95
|
+
NodeLedger.entries(path)
|
|
96
|
+
.map { |entry| entry[:subject] }
|
|
97
|
+
.reject { |subject| subject == Savepoint::INTENT_SUBJECT }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "strscan"
|
|
5
|
+
require_relative "savepoint"
|
|
6
|
+
require_relative "guarded_append"
|
|
7
|
+
|
|
8
|
+
# NodeLedger - the node and intent transition line (intent 335, G2). Owns the
|
|
9
|
+
# line's byte-exact format, the closed state vocabulary, the required fields per
|
|
10
|
+
# state, the parser, torn-line and unattributed-line detection, status as the
|
|
11
|
+
# last line per subject in file order, and the guarded transition append that
|
|
12
|
+
# skips dedup entirely (spec D11).
|
|
13
|
+
#
|
|
14
|
+
# Per spec D17 the subject vocabulary (`Savepoint::INTENT_SUBJECT`,
|
|
15
|
+
# `Savepoint::NODE_SUBJECT_RE`, `Savepoint.transition_candidate?`) lives on
|
|
16
|
+
# Savepoint, not here: this file requires savepoint.rb and reuses them, the
|
|
17
|
+
# dependency running one way only (test/savepoint_split_test.rb:57 pins
|
|
18
|
+
# savepoint.rb to loading no other project file).
|
|
19
|
+
#
|
|
20
|
+
# Pure and dependency-injected: every path and clock is an argument, `guard:` is
|
|
21
|
+
# an injectable seam for the transition append, and this module reads no
|
|
22
|
+
# environment variable.
|
|
23
|
+
module NodeLedger
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Ten states, closed (spec D1). "ready" is computed and never written.
|
|
27
|
+
STATES = %w[
|
|
28
|
+
planned running done failed_verification needs_decision blocked deferred
|
|
29
|
+
superseded abandoned reclaimed
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# A written state's RESOLVED state for the ready function 336 builds on top of
|
|
33
|
+
# this ledger (spec D2): every state resolves to itself except `reclaimed`,
|
|
34
|
+
# which resolves to `planned` ("a reclaimed node is planned again").
|
|
35
|
+
RESOLUTION = { "reclaimed" => "planned" }.freeze
|
|
36
|
+
|
|
37
|
+
# Required fields per state (spec D4), refused by #append_transition and read
|
|
38
|
+
# as torn by #torn? when absent. `done`'s evidence requirement (gates= plus at
|
|
39
|
+
# least one of commit=/verdict=) is asymmetric, so it is not representable as
|
|
40
|
+
# a flat list; DONE_EVIDENCE_FIELDS below carries the "at least one of" half.
|
|
41
|
+
REQUIRED_FIELDS = {
|
|
42
|
+
"planned" => [],
|
|
43
|
+
"running" => %w[holder expires packet model],
|
|
44
|
+
"done" => %w[gates],
|
|
45
|
+
"failed_verification" => %w[gates reason],
|
|
46
|
+
"needs_decision" => %w[question],
|
|
47
|
+
"blocked" => %w[reason],
|
|
48
|
+
"deferred" => %w[reason],
|
|
49
|
+
"superseded" => %w[by],
|
|
50
|
+
"abandoned" => %w[reason],
|
|
51
|
+
"reclaimed" => %w[holder expired],
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
# `done` requires gates= plus at least one of these (spec D7). `model=` is
|
|
55
|
+
# deliberately absent from every required-fields list except `running` (spec
|
|
56
|
+
# D6) and is accepted, never required, everywhere else - no separate table
|
|
57
|
+
# entry is needed for that: an unlisted field is never required.
|
|
58
|
+
DONE_EVIDENCE_FIELDS = %w[commit verdict].freeze
|
|
59
|
+
|
|
60
|
+
# Canonical field render order (spec D8's "stable declared order", matrix
|
|
61
|
+
# 2.6): every key named in REQUIRED_FIELDS or DONE_EVIDENCE_FIELDS, plus
|
|
62
|
+
# `model` (accepted on any state). A field outside this list still renders,
|
|
63
|
+
# 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
|
+
SEPARATOR = " "
|
|
67
|
+
|
|
68
|
+
# The field key character class, shared by the emitter (#render_fields, below) and
|
|
69
|
+
# the parser (FIELD_TOKEN_RE, further down): a key outside this class is a key the
|
|
70
|
+
# parser can never read back as a pair, so it must never be emitted (post-execution
|
|
71
|
+
# review row 7.7). One source of truth for the char class keeps the two families
|
|
72
|
+
# from drifting the way they did before this row's fix.
|
|
73
|
+
FIELD_KEY_CHARS = "[a-z_]+"
|
|
74
|
+
FIELD_KEY_RE = /\A#{FIELD_KEY_CHARS}\z/.freeze
|
|
75
|
+
|
|
76
|
+
# timestamp<SEP>subject<SEP>state field=value... (comment). Two-space
|
|
77
|
+
# separators keep the line a three-field line under both `split(/\s{2,}/)`
|
|
78
|
+
# and every reader's own `SAVEPOINT_RE` (spec "Line shape"). Raises
|
|
79
|
+
# ArgumentError, writing nothing, for an unknown state or a missing required
|
|
80
|
+
# field (D4/D9): the caller of #append_transition relies on this check
|
|
81
|
+
# running BEFORE the file is ever touched.
|
|
82
|
+
def transition_line(subject:, state:, fields: {}, comment: nil, now: Time.now)
|
|
83
|
+
state = state.to_s
|
|
84
|
+
raise ArgumentError, "unknown transition state: #{state.inspect}" unless STATES.include?(state)
|
|
85
|
+
|
|
86
|
+
missing = missing_fields(state, fields)
|
|
87
|
+
raise ArgumentError, "state #{state} requires #{missing.join(', ')}" if missing.any?
|
|
88
|
+
|
|
89
|
+
timestamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
90
|
+
rest = ([state] + render_fields(fields)).join(" ")
|
|
91
|
+
rest = "#{rest} (#{normalize_value(comment)})" if comment && !comment.to_s.strip.empty?
|
|
92
|
+
"#{timestamp}#{SEPARATOR}#{subject}#{SEPARATOR}#{rest}\n"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Collapse every run of two-or-more WHITESPACE characters in `text` to one
|
|
96
|
+
# (spec "Line shape": "collapses every whitespace run inside a field value
|
|
97
|
+
# to a single space"), so a caller-supplied value or comment can never
|
|
98
|
+
# reintroduce a two-or-more run the line's own separators rely on being
|
|
99
|
+
# unique to field boundaries. A tab or a newline is refused outright (raises
|
|
100
|
+
# ArgumentError) rather than collapsed: silently eating either would hide a
|
|
101
|
+
# value that could otherwise tear a ledger line. Post-execution review row
|
|
102
|
+
# 7.6: a bare `/ {2,}/` collapsed only literal spaces, so a carriage return,
|
|
103
|
+
# vertical tab or form feed beside a space still reached field 3 as a
|
|
104
|
+
# two-whitespace run that `split(/\s{2,}/)` reads as four fields while
|
|
105
|
+
# SAVEPOINT_RE reads as three - `\s{2,}` is what the acceptance criterion
|
|
106
|
+
# ("no run of two or more whitespace characters ever reaches field 3")
|
|
107
|
+
# actually requires.
|
|
108
|
+
def normalize_value(text)
|
|
109
|
+
value = text.to_s
|
|
110
|
+
raise ArgumentError, "value must not contain a tab or a newline: #{value.inspect}" if value =~ /[\t\n]/
|
|
111
|
+
|
|
112
|
+
value.gsub(/\s{2,}/, " ")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A required-field D4 check independent of vocabulary validity: given a state
|
|
116
|
+
# (assumed valid) and a fields hash, the list of required field names still
|
|
117
|
+
# missing. `done`'s "at least one of commit=/verdict=" half renders as the
|
|
118
|
+
# literal string "commit or verdict" when both are absent.
|
|
119
|
+
def missing_fields(state, fields)
|
|
120
|
+
normalized = stringify_keys(fields)
|
|
121
|
+
required = REQUIRED_FIELDS.fetch(state.to_s, [])
|
|
122
|
+
missing = required.reject { |key| present?(normalized[key]) }
|
|
123
|
+
if state.to_s == "done" && DONE_EVIDENCE_FIELDS.none? { |key| present?(normalized[key]) }
|
|
124
|
+
missing += ["commit or verdict"]
|
|
125
|
+
end
|
|
126
|
+
missing
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def present?(value)
|
|
130
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
131
|
+
end
|
|
132
|
+
private_class_method :present?
|
|
133
|
+
|
|
134
|
+
def stringify_keys(fields)
|
|
135
|
+
(fields || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
136
|
+
end
|
|
137
|
+
private_class_method :stringify_keys
|
|
138
|
+
|
|
139
|
+
def render_fields(fields)
|
|
140
|
+
normalized = stringify_keys(fields)
|
|
141
|
+
unreadable = normalized.keys.reject { |key| key.match?(FIELD_KEY_RE) }
|
|
142
|
+
if unreadable.any?
|
|
143
|
+
raise ArgumentError, "field key(s) the parser cannot read back: #{unreadable.join(', ')}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
known = FIELD_ORDER.select { |key| normalized.key?(key) }
|
|
147
|
+
extra = (normalized.keys - FIELD_ORDER).sort
|
|
148
|
+
(known + extra).map { |key| "#{key}=#{render_value(normalized[key])}" }
|
|
149
|
+
end
|
|
150
|
+
private_class_method :render_fields
|
|
151
|
+
|
|
152
|
+
def render_value(value)
|
|
153
|
+
text = normalize_value(value)
|
|
154
|
+
return text if bare_safe?(text)
|
|
155
|
+
|
|
156
|
+
escaped = text.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }
|
|
157
|
+
"\"#{escaped}\""
|
|
158
|
+
end
|
|
159
|
+
private_class_method :render_value
|
|
160
|
+
|
|
161
|
+
def bare_safe?(text)
|
|
162
|
+
!text.empty? && !text.match?(/[\s"\\]/)
|
|
163
|
+
end
|
|
164
|
+
private_class_method :bare_safe?
|
|
165
|
+
|
|
166
|
+
# --- Parsing ---------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
# timestamp<2sp>subject<2sp>rest, mirroring intent_screen.rb's SAVEPOINT_RE
|
|
169
|
+
# (the same three-field contract every reader in the tree already assumes).
|
|
170
|
+
TRANSITION_LINE_RE = /\A(\S+)#{SEPARATOR}(\S+)#{SEPARATOR}(.+?)\s*\z/.freeze
|
|
171
|
+
private_constant :TRANSITION_LINE_RE
|
|
172
|
+
|
|
173
|
+
FIELD_TOKEN_RE = /(#{FIELD_KEY_CHARS})=("(?:[^"\\]|\\.)*"|[^\s"]+)/.freeze
|
|
174
|
+
private_constant :FIELD_TOKEN_RE
|
|
175
|
+
|
|
176
|
+
# Parse one raw ledger line into {timestamp:, subject:, state:, fields:,
|
|
177
|
+
# comment:, raw:}, or nil when it does not even split into the three fields
|
|
178
|
+
# every transition line has. `#scrub`s the line first (mirrors
|
|
179
|
+
# SessionLedger.parse_checklist_line) so a stray non-UTF-8 byte anywhere in
|
|
180
|
+
# the ledger never raises out of the parser and takes down every reader
|
|
181
|
+
# (matrix 2.44); it only ever affects that one line's parsed text.
|
|
182
|
+
def parse_transition_line(line)
|
|
183
|
+
raw = line.to_s.chomp.scrub
|
|
184
|
+
m = TRANSITION_LINE_RE.match(raw)
|
|
185
|
+
return nil unless m
|
|
186
|
+
|
|
187
|
+
timestamp, subject, rest = m.captures
|
|
188
|
+
state, remainder = rest.split(/\s+/, 2)
|
|
189
|
+
fields, comment = scan_fields(remainder.to_s)
|
|
190
|
+
{ timestamp: timestamp, subject: subject, state: state, fields: fields, comment: comment, raw: raw }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Scan `rest` (everything after the state token) for `key=value` pairs, quote-
|
|
194
|
+
# and-escape aware, greedily from the start; the first token that is not a
|
|
195
|
+
# pair, and everything after it, is the trailing comment (spec D8), with one
|
|
196
|
+
# layer of wrapping parens stripped when present (the shape #transition_line
|
|
197
|
+
# itself always emits). Returns [fields_hash, comment_or_nil].
|
|
198
|
+
def scan_fields(rest)
|
|
199
|
+
scanner = StringScanner.new(rest)
|
|
200
|
+
fields = {}
|
|
201
|
+
loop do
|
|
202
|
+
scanner.skip(/\s+/)
|
|
203
|
+
break if scanner.eos?
|
|
204
|
+
|
|
205
|
+
start_pos = scanner.pos
|
|
206
|
+
if scanner.scan(FIELD_TOKEN_RE)
|
|
207
|
+
fields[scanner[1]] = unquote(scanner[2])
|
|
208
|
+
else
|
|
209
|
+
scanner.pos = start_pos
|
|
210
|
+
break
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
tail = scanner.rest.to_s.strip
|
|
214
|
+
comment = tail.empty? ? nil : tail.sub(/\A\((.*)\)\z/m, '\1')
|
|
215
|
+
[fields, comment]
|
|
216
|
+
end
|
|
217
|
+
private_class_method :scan_fields
|
|
218
|
+
|
|
219
|
+
def unquote(raw)
|
|
220
|
+
return raw unless raw.start_with?('"')
|
|
221
|
+
|
|
222
|
+
raw[1..-2].gsub(/\\(.)/) { Regexp.last_match(1) }
|
|
223
|
+
end
|
|
224
|
+
private_class_method :unquote
|
|
225
|
+
|
|
226
|
+
# A transition line is torn (spec D9) when it does not parse into the three
|
|
227
|
+
# fields at all, when its state token is outside STATES, or when its state is
|
|
228
|
+
# in STATES but a field REQUIRED_FIELDS demands is missing. The second half
|
|
229
|
+
# matters more than the first: a crash-truncated write usually keeps a valid
|
|
230
|
+
# state token and loses the tail (`n1 running holder=auto-ce5`), which is
|
|
231
|
+
# the realistic torn line, not a truncated state token.
|
|
232
|
+
def torn?(line)
|
|
233
|
+
parsed = parse_transition_line(line)
|
|
234
|
+
return true unless parsed
|
|
235
|
+
return true unless parsed[:state] && STATES.include?(parsed[:state])
|
|
236
|
+
|
|
237
|
+
missing_fields(parsed[:state], parsed[:fields]).any?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def torn_reason(parsed)
|
|
241
|
+
return "does not parse as timestamp subject state ..." unless parsed && parsed[:state]
|
|
242
|
+
return "unknown state #{parsed[:state].inspect}" unless STATES.include?(parsed[:state])
|
|
243
|
+
|
|
244
|
+
"missing required field(s): #{missing_fields(parsed[:state], parsed[:fields]).join(', ')}"
|
|
245
|
+
end
|
|
246
|
+
private_class_method :torn_reason
|
|
247
|
+
|
|
248
|
+
# A transition line is attributed (spec D10) iff it carries a non-blank
|
|
249
|
+
# `holder=` field. Unattributed and torn are independent: a line can be
|
|
250
|
+
# unattributed and otherwise well formed at the same time.
|
|
251
|
+
def attributed?(parsed)
|
|
252
|
+
return false unless parsed
|
|
253
|
+
|
|
254
|
+
present?((parsed[:fields] || {})["holder"])
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def resolved_state(state)
|
|
258
|
+
RESOLUTION.fetch(state.to_s, state.to_s)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# --- Reading the ledger ------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
# Every transition candidate line in `path`, in file order, each annotated
|
|
264
|
+
# with its parse, torn-ness, and attribution. Non-candidate lines (a stage
|
|
265
|
+
# line, a Lock takeover line) are excluded entirely: they are never a
|
|
266
|
+
# transition and are never reported as torn by this module (spec Acceptance
|
|
267
|
+
# Criteria; Savepoint's own phantom detector covers the stage family).
|
|
268
|
+
# `#scrub`s the whole file up front (matrix 2.44) so one bad byte anywhere
|
|
269
|
+
# never raises out of this reader.
|
|
270
|
+
def entries(path)
|
|
271
|
+
return [] unless path && File.exist?(path)
|
|
272
|
+
|
|
273
|
+
entries_from_content(File.read(path))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Same as #entries, but over an in-memory string rather than a path (post-
|
|
277
|
+
# execution review row 7.1/7.2): the readiness decision `node-transition`
|
|
278
|
+
# makes for `running` must be evaluated against the exact content
|
|
279
|
+
# GuardedAppend read under its lock hold, never a re-read of the path (which
|
|
280
|
+
# could observe a different file than the one the guard is holding closed
|
|
281
|
+
# against other writers, and reintroduces the check-then-append gap this row
|
|
282
|
+
# exists to close).
|
|
283
|
+
def entries_from_content(content)
|
|
284
|
+
content.to_s.scrub.each_line.filter_map do |raw|
|
|
285
|
+
line = raw.chomp
|
|
286
|
+
next nil if line.strip.empty?
|
|
287
|
+
next nil unless Savepoint.transition_candidate?(line)
|
|
288
|
+
|
|
289
|
+
parsed = parse_transition_line(line)
|
|
290
|
+
torn = torn?(line)
|
|
291
|
+
{
|
|
292
|
+
raw: line,
|
|
293
|
+
timestamp: parsed && parsed[:timestamp],
|
|
294
|
+
subject: parsed ? parsed[:subject] : line.split(/\s{2,}/)[1],
|
|
295
|
+
state: parsed && parsed[:state],
|
|
296
|
+
fields: parsed ? parsed[:fields] : {},
|
|
297
|
+
comment: parsed && parsed[:comment],
|
|
298
|
+
torn: torn,
|
|
299
|
+
attributed: parsed ? attributed?(parsed) : false,
|
|
300
|
+
}
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Status per subject: the last NON-TORN line for that subject in FILE order
|
|
305
|
+
# (never by timestamp, spec Acceptance Criteria), resolved (spec D2). An
|
|
306
|
+
# unattributed-but-well-formed line still counts for status (D10: status
|
|
307
|
+
# shows it; only the readiness check ignores it).
|
|
308
|
+
def status(path)
|
|
309
|
+
status_from_content(path && File.exist?(path) ? File.read(path) : "")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Content-based counterpart to #status (see #entries_from_content).
|
|
313
|
+
def status_from_content(content)
|
|
314
|
+
entries_from_content(content).each_with_object({}) do |entry, memo|
|
|
315
|
+
next if entry[:torn]
|
|
316
|
+
|
|
317
|
+
memo[entry[:subject]] = resolved_state(entry[:state])
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# A subject with no line at all reads as "planned" (spec D1's implicit
|
|
322
|
+
# starting state), never raises.
|
|
323
|
+
def status_for(path, subject)
|
|
324
|
+
status(path).fetch(subject.to_s, "planned")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Content-based counterpart to #status_for (see #entries_from_content).
|
|
328
|
+
def status_for_content(content, subject)
|
|
329
|
+
status_from_content(content).fetch(subject.to_s, "planned")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The last (file-order) non-torn `running` entry for `subject`, or nil. Used
|
|
333
|
+
# by the reclaim precondition (S3) to find the `expires=` this subject's live
|
|
334
|
+
# dispatch carries.
|
|
335
|
+
def last_running(path, subject)
|
|
336
|
+
entries(path).select { |e| !e[:torn] && e[:subject] == subject.to_s && e[:state] == "running" }.last
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Torn and unattributed lines, each paired with a reason (spec C2/C5's
|
|
340
|
+
# reader): [{line:, reason:}, ...]. A clean or absent ledger returns []. A
|
|
341
|
+
# torn line is reported once, as torn; a well-formed unattributed line is
|
|
342
|
+
# reported once, as unattributed - the two categories never conflate.
|
|
343
|
+
def anomalies(path)
|
|
344
|
+
entries(path).filter_map do |entry|
|
|
345
|
+
if entry[:torn]
|
|
346
|
+
{ line: entry[:raw], reason: "torn: #{torn_reason(parse_transition_line(entry[:raw]))}" }
|
|
347
|
+
elsif !entry[:attributed]
|
|
348
|
+
{ line: entry[:raw], reason: "unattributed (no holder=)" }
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# --- Writing -----------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
# Refuse an unknown state or a missing required field BEFORE touching the
|
|
356
|
+
# file (spec C20), then append through `guard` (default GuardedAppend,
|
|
357
|
+
# strict), skipping dedup entirely (spec D11: transition lines never consult
|
|
358
|
+
# savepoint_recorded_pairs). Returns whatever `guard.call` returns
|
|
359
|
+
# (:written or :refused); propagates GuardedAppend::Unavailable rather than
|
|
360
|
+
# swallowing it (a caller must never believe a line landed when it did not).
|
|
361
|
+
#
|
|
362
|
+
# `precondition:` (post-execution review rows 7.1-7.3) is an optional
|
|
363
|
+
# callable evaluated INSIDE the guard's lock hold, against the exact
|
|
364
|
+
# `content` the guard just read - never a value captured before the call.
|
|
365
|
+
# When it returns falsy, the block returns nil (GuardedAppend's own
|
|
366
|
+
# refusal contract) and nothing is written; the caller sees :refused,
|
|
367
|
+
# distinguishable from :written, and must not believe the line landed. This
|
|
368
|
+
# is what makes "is the subject still ready" and "append running" atomic
|
|
369
|
+
# against a second writer: a check followed by a separate append never is.
|
|
370
|
+
def append_transition(path, subject:, state:, fields: {}, comment: nil, now: Time.now, guard: GuardedAppend,
|
|
371
|
+
precondition: nil)
|
|
372
|
+
line = transition_line(subject: subject, state: state, fields: fields, comment: comment, now: now)
|
|
373
|
+
guard.call(path, strict: true) do |content|
|
|
374
|
+
precondition && !precondition.call(content) ? nil : line
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|