@zalom/plastic 2.0.0-alpha.18 → 2.0.0-alpha.19

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 (45) hide show
  1. package/package.json +2 -2
  2. package/scripts/dashboard.rb +20 -0
  3. package/scripts/doctor.rb +120 -2
  4. package/scripts/end-intent +134 -8
  5. package/scripts/lib/action_graph_shim.rb +277 -0
  6. package/scripts/lib/atomic_write.rb +31 -0
  7. package/scripts/lib/graph_edges.rb +121 -0
  8. package/scripts/lib/graph_file.rb +246 -0
  9. package/scripts/lib/guarded_append.rb +155 -0
  10. package/scripts/lib/installer_core.rb +32 -0
  11. package/scripts/lib/node_file.rb +214 -0
  12. package/scripts/lib/node_ids.rb +99 -0
  13. package/scripts/lib/node_ledger.rb +377 -0
  14. package/scripts/lib/node_packet.rb +873 -0
  15. package/scripts/lib/outcome_report.rb +440 -0
  16. package/scripts/lib/packet_wrapper.rb +132 -0
  17. package/scripts/lib/ready_set.rb +462 -0
  18. package/scripts/lib/release_guard.rb +16 -0
  19. package/scripts/lib/report_screen.rb +122 -12
  20. package/scripts/lib/roadmap_queue.rb +161 -3
  21. package/scripts/lib/roadmap_savepoint.rb +26 -5
  22. package/scripts/lib/savepoint.rb +123 -12
  23. package/scripts/lib/work_graph_validator.rb +201 -0
  24. package/scripts/node-packet +92 -0
  25. package/scripts/node-transition +291 -0
  26. package/scripts/outcome-report +74 -0
  27. package/scripts/ready-set +126 -0
  28. package/scripts/release-check +118 -0
  29. package/scripts/report-screen +8 -1
  30. package/scripts/roadmap-savepoint +7 -0
  31. package/scripts/validate-work-graph +39 -0
  32. package/skills/auto/SKILL.md +2 -3
  33. package/skills/auto/references/human-report-contract.md +3 -2
  34. package/skills/intent-continuing/references/boarding-matrix.md +1 -0
  35. package/skills/intent-ending/SKILL.md +30 -19
  36. package/skills/intent-executing/SKILL.md +1 -1
  37. package/skills/releasing/SKILL.md +39 -0
  38. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  39. package/skills/releasing/references/release-lines.md +1 -1
  40. package/templates/graph.md +16 -0
  41. package/templates/node-decision.md +11 -0
  42. package/templates/node-research.md +11 -0
  43. package/templates/node-verify.md +13 -0
  44. package/templates/node-work.md +22 -0
  45. package/templates/outcome.md +8 -6
@@ -0,0 +1,155 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # GuardedAppend - the shared fail-closed write guard behind NodeLedger's transition
5
+ # lines and RoadmapSavepoint's roadmap ledger (intent 335, spec "Approach").
6
+ #
7
+ # One module function, #call: opens `path` RDWR|APPEND|CREAT, takes a non-blocking
8
+ # exclusive lock with a bounded number of retries, and under that ONE hold reads the
9
+ # current content, yields it to the caller's block, and appends whatever the block
10
+ # returns. A block returning nil is a refusal: nothing is written. Read, decide, and
11
+ # append happen inside one lock hold on purpose (spec "Approach"): that is what makes
12
+ # "is this subject already running" and "append running" atomic against a second
13
+ # writer, which a check followed by a separate append could never be.
14
+ #
15
+ # Pure and dependency-injected: `flock:` and `sleeper:` are constructor-style test
16
+ # seams (never an environment variable, never a global); this module reads no
17
+ # environment variable and shells out to nothing.
18
+ module GuardedAppend
19
+ # Raised when the lock could not be taken within `retries` attempts (real
20
+ # contention), or when flock itself is unsupported on this filesystem and
21
+ # `strict: true` (spec D12a). Nothing is written either way; the caller is told
22
+ # plainly that nothing landed and must retry.
23
+ class Unavailable < StandardError; end
24
+
25
+ module_function
26
+
27
+ # Five attempts, 20 ms apart: about 100 ms of wall time total (spec D12).
28
+ DEFAULT_RETRIES = 5
29
+ DEFAULT_BACKOFF = 0.02
30
+
31
+ # Default lock and sleep seams: a real flock call, a real sleep. Tests inject
32
+ # replacements to simulate contention, recovery, and a flock-less filesystem
33
+ # hermetically, with no need for a real flock-less mount or a slow test run.
34
+ DEFAULT_FLOCK = ->(handle, mode) { handle.flock(mode) }
35
+ private_constant :DEFAULT_FLOCK
36
+
37
+ DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
38
+ private_constant :DEFAULT_SLEEPER
39
+
40
+ # Open `path` (creating it if absent, never truncating it), take an exclusive
41
+ # non-blocking lock with up to `retries` attempts (`backoff` seconds apart), and
42
+ # under that one hold read the file's current content, yield it to the block, and
43
+ # append what the block returns.
44
+ #
45
+ # Returns :written when a line was appended, :refused when the block returned nil
46
+ # (nothing written, spec: a refusal). Raises Unavailable, writing nothing, when the
47
+ # lock could not be taken within `retries` attempts.
48
+ #
49
+ # `strict:` decides what happens when flock itself raises a SystemCallError OTHER
50
+ # than contention (EWOULDBLOCK/EAGAIN) - a filesystem without flock support,
51
+ # distinct from real contention (spec D12a): strict (the default) raises
52
+ # Unavailable; non-strict proceeds unguarded, since a single O_APPEND write still
53
+ # lands whole there. The SystemCallError rescue wraps the flock call only (spec
54
+ # D12b); an Errno::ENOENT from File.open (a missing parent directory) propagates
55
+ # as itself, never read as Unavailable.
56
+ def call(path, retries: DEFAULT_RETRIES, backoff: DEFAULT_BACKOFF, strict: true,
57
+ flock: DEFAULT_FLOCK, sleeper: DEFAULT_SLEEPER, &block)
58
+ created = !File.exist?(path)
59
+ handle = File.open(path, File::RDWR | File::APPEND | File::CREAT, 0o644)
60
+ begin
61
+ status = take_lock(handle, retries: retries, backoff: backoff, flock: flock, sleeper: sleeper)
62
+
63
+ case status
64
+ when :contended
65
+ raise Unavailable, "could not take an exclusive lock on #{path} after #{retries} attempts"
66
+ when :unsupported
67
+ if strict
68
+ raise Unavailable, "flock is unsupported on #{path} and strict: true refuses to proceed unguarded"
69
+ end
70
+
71
+ write_line(handle, &block)
72
+ when :locked
73
+ begin
74
+ write_line(handle, &block)
75
+ ensure
76
+ unlock(handle, flock: flock)
77
+ end
78
+ end
79
+ ensure
80
+ handle.close
81
+ end
82
+ rescue Unavailable
83
+ remove_freshly_created_empty_file(path) if created
84
+ raise
85
+ end
86
+
87
+ # Attempt the lock up to `retries` times. Returns :locked, :unsupported (a
88
+ # non-contention SystemCallError from flock, decided once, never retried), or
89
+ # :contended (every attempt failed with EWOULDBLOCK/EAGAIN or a false return).
90
+ # Sleeps `backoff` seconds after EVERY contended attempt, including the last, so
91
+ # the total backoff budget is exactly `retries` sleeps (spec D12: "about 100 ms of
92
+ # wall time in total" = 5 attempts * 20 ms, not 4).
93
+ def take_lock(handle, retries:, backoff:, flock:, sleeper:)
94
+ status = :contended
95
+ retries.times do
96
+ status = try_flock(handle, flock)
97
+ return status unless status == :contended
98
+
99
+ sleeper.call(backoff)
100
+ end
101
+ status
102
+ end
103
+ private_class_method :take_lock
104
+
105
+ # One attempt at the non-blocking exclusive lock. File#flock RAISES (does not
106
+ # return false) for every errno except EWOULDBLOCK/EAGAIN on most platforms, so
107
+ # both the "returns false" and the "raises EWOULDBLOCK" shapes read as contention;
108
+ # any other SystemCallError means flock is not supported on this filesystem.
109
+ def try_flock(handle, flock)
110
+ result = flock.call(handle, File::LOCK_EX | File::LOCK_NB)
111
+ result == false ? :contended : :locked
112
+ rescue Errno::EWOULDBLOCK, Errno::EAGAIN
113
+ :contended
114
+ rescue SystemCallError
115
+ :unsupported
116
+ end
117
+ private_class_method :try_flock
118
+
119
+ # Read the current content, yield it to the block, and write what it returns. A
120
+ # block returning nil writes nothing and reports :refused. Before a real write, if
121
+ # the content is non-empty and does not end in a newline, a newline is written
122
+ # first (spec D9a): a crash that truncated the previous write must not glue the
123
+ # next transition onto its tail. The handle is opened O_APPEND, so every write
124
+ # lands at the current end of file regardless of the read's cursor position.
125
+ def write_line(handle, &block)
126
+ content = handle.read
127
+ line = block.call(content)
128
+ return :refused if line.nil?
129
+
130
+ prefix = !content.empty? && !content.end_with?("\n") ? "\n" : ""
131
+ handle.write("#{prefix}#{line}")
132
+ handle.flush
133
+ :written
134
+ end
135
+ private_class_method :write_line
136
+
137
+ def unlock(handle, flock:)
138
+ flock.call(handle, File::LOCK_UN)
139
+ rescue SystemCallError
140
+ nil
141
+ end
142
+ private_class_method :unlock
143
+
144
+ # Row 7.9 (post-execution review): a give-up (Unavailable) must not leave a zero-byte
145
+ # file behind that File::CREAT created for a target that did not exist before this
146
+ # call. Only ever removes a file this same call created (never a pre-existing file,
147
+ # spec matrix 1.11) and only when it is still empty (no write ever reached it on the
148
+ # give-up paths this rescues).
149
+ def remove_freshly_created_empty_file(path)
150
+ File.unlink(path) if File.exist?(path) && File.zero?(path)
151
+ rescue SystemCallError
152
+ nil
153
+ end
154
+ private_class_method :remove_freshly_created_empty_file
155
+ end
@@ -373,6 +373,9 @@ class InstallerCore
373
373
  "scripts/lib/release_guard.rb" => "scripts/lib/release_guard.rb",
374
374
  "scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
375
375
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
376
+ "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
377
+ "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
378
+ "scripts/node-transition" => "scripts/node-transition",
376
379
  "scripts/lib/arm.rb" => "scripts/lib/arm.rb",
377
380
  "scripts/lib/lock.rb" => "scripts/lib/lock.rb",
378
381
  "scripts/plastic-lock" => "scripts/plastic-lock",
@@ -475,6 +478,35 @@ class InstallerCore
475
478
  # and need no entry here.
476
479
  "scripts/lib/dashboard_screen.rb" => "scripts/lib/dashboard_screen.rb",
477
480
  "scripts/hook-message-display" => "scripts/hook-message-display",
481
+ # Intent 334 (G1): the node file and graph.md library, plus its
482
+ # validator and CLI (327 D40/D41).
483
+ "scripts/lib/graph_edges.rb" => "scripts/lib/graph_edges.rb",
484
+ "scripts/lib/node_file.rb" => "scripts/lib/node_file.rb",
485
+ "scripts/lib/atomic_write.rb" => "scripts/lib/atomic_write.rb",
486
+ "scripts/lib/graph_file.rb" => "scripts/lib/graph_file.rb",
487
+ "scripts/lib/work_graph_validator.rb" => "scripts/lib/work_graph_validator.rb",
488
+ "scripts/validate-work-graph" => "scripts/validate-work-graph",
489
+ # Intent 335a: every id an intent has ever seen, so a deleted node's id
490
+ # is never reissued to a new node with the dead one's ledger history.
491
+ "scripts/lib/node_ids.rb" => "scripts/lib/node_ids.rb",
492
+ # Intent 339 (G6): the generated outcome.md library and its CLI.
493
+ "scripts/lib/outcome_report.rb" => "scripts/lib/outcome_report.rb",
494
+ "scripts/outcome-report" => "scripts/outcome-report",
495
+ # Intent 336 (G3): ReadySet, the one function that says what may run
496
+ # next, and its CLI. node-transition requires the lib as of n5;
497
+ # registered here (rather than waiting for n8's own CLI/doc unit) so
498
+ # test/install_sync_test.rb stays green across every intermediate unit.
499
+ "scripts/lib/ready_set.rb" => "scripts/lib/ready_set.rb",
500
+ "scripts/ready-set" => "scripts/ready-set",
501
+ # Intent 338 (G5): the node packet command - the trust-boundary wrapper,
502
+ # the five-block gatherer/assembler, and the CLI 340's runner calls.
503
+ "scripts/lib/packet_wrapper.rb" => "scripts/lib/packet_wrapper.rb",
504
+ "scripts/lib/node_packet.rb" => "scripts/lib/node_packet.rb",
505
+ "scripts/node-packet" => "scripts/node-packet",
506
+ # Intent 342 (G9): the backward shim that presents actions/*.md as a
507
+ # node graph for any legacy intent, so WorkGraphValidator can require
508
+ # it without going red on contact with install_sync_test.
509
+ "scripts/lib/action_graph_shim.rb" => "scripts/lib/action_graph_shim.rb",
478
510
  }
479
511
  end
480
512
 
@@ -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