@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,179 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "yaml"
6
+ require "time"
7
+ require "fileutils"
8
+ require "rbconfig"
9
+ require_relative "atomic_write"
10
+
11
+ # MeterWatch (intent 355, n5, D6): reads the owner's rate-limit cache on a
12
+ # timer and writes one state file a session watches, instead of every session
13
+ # parsing the cache and re-deriving the thresholds for itself. No model call
14
+ # is spent on a tick that leaves the state unchanged.
15
+ #
16
+ # Everything is injected: home (holds config.yml and .cache/), the clock, the
17
+ # cache path, and the renamer AtomicWrite uses. Nothing reads ENV, nothing
18
+ # calls launchctl, and --install-timer (scripts/meter-watch) only ever writes
19
+ # under the injected home.
20
+ class MeterWatch
21
+ DEFAULT_REDUCE_AT = 55
22
+ DEFAULT_STOP_AT = 85
23
+ DEFAULT_WEEKLY_STOP_AT = 97
24
+ TICK_SECONDS = 20 * 60
25
+ STALE_AFTER_SECONDS = TICK_SECONDS * 2
26
+
27
+ def initialize(home:, cache_path: nil, config_path: nil, now: Time.now, renamer: File.method(:rename))
28
+ @home = home
29
+ @cache_path = cache_path || File.join(home, ".cache", "rate-limits.json")
30
+ @config_path = config_path || File.join(home, "config.yml")
31
+ @state_path = File.join(home, ".cache", "meter-state.json")
32
+ @now = now
33
+ @renamer = renamer
34
+ @reduce_at, @stop_at, @weekly_stop_at = load_thresholds
35
+ end
36
+
37
+ attr_reader :state_path
38
+
39
+ def tick
40
+ previous = read_state
41
+ state = compute_state(previous)
42
+ write_if_changed(previous, state)
43
+ end
44
+
45
+ private
46
+
47
+ def compute_state(previous)
48
+ return base_state("unavailable") unless File.file?(@cache_path)
49
+ return stopped?(previous) ? previous : base_state("stale") if stale?
50
+
51
+ cache = JSON.parse(File.read(@cache_path))
52
+ five_hour = cache["five_hour"]
53
+ seven_day = cache["seven_day"]
54
+ resets_at = cache["resets_at"]
55
+
56
+ # Resume compares `now` against the STOP's OWN resets_at (carried
57
+ # forward on `previous`, from the tick that first wrote "stop"), never
58
+ # the cache's current resets_at: the cache moves resets_at on to the
59
+ # NEXT window before five_hour/seven_day themselves drop, so comparing
60
+ # against the live value would never report resume (B4).
61
+ label = if stopped?(previous) && reset_passed?(previous["resets_at"])
62
+ "resume"
63
+ else
64
+ classify(five_hour, seven_day)
65
+ end
66
+
67
+ base_state(label, five_hour: five_hour, seven_day: seven_day, resets_at: resets_at)
68
+ rescue JSON::ParserError
69
+ base_state("unavailable")
70
+ end
71
+
72
+ def stopped?(previous)
73
+ previous && previous["state"] == "stop"
74
+ end
75
+
76
+ def classify(five_hour, seven_day)
77
+ return "stop" if five_hour.to_f >= @stop_at
78
+ return "stop" if seven_day.to_f >= @weekly_stop_at
79
+ return "reduce" if five_hour.to_f >= @reduce_at
80
+
81
+ "ok"
82
+ end
83
+
84
+ def stale?
85
+ File.mtime(@cache_path) < (@now - STALE_AFTER_SECONDS)
86
+ rescue Errno::ENOENT
87
+ true
88
+ end
89
+
90
+ def reset_passed?(resets_at)
91
+ at = parse_time(resets_at)
92
+ at && @now >= at
93
+ end
94
+
95
+ def parse_time(value)
96
+ return nil if value.nil? || value.to_s.empty?
97
+
98
+ text = value.to_s
99
+ text.match?(/\A\d+\z/) ? Time.at(text.to_i).utc : Time.iso8601(text)
100
+ rescue ArgumentError, TypeError
101
+ nil
102
+ end
103
+
104
+ def base_state(label, five_hour: nil, seven_day: nil, resets_at: nil)
105
+ {
106
+ "state" => label,
107
+ "five_hour" => five_hour,
108
+ "seven_day" => seven_day,
109
+ "resets_at" => resets_at,
110
+ "checked_at" => @now.getutc.iso8601,
111
+ }
112
+ end
113
+
114
+ def write_if_changed(previous, state)
115
+ return state if previous && previous["state"] == state["state"]
116
+
117
+ FileUtils.mkdir_p(File.dirname(@state_path))
118
+ AtomicWrite.write(@state_path, JSON.generate(state), renamer: @renamer)
119
+ state
120
+ end
121
+
122
+ def read_state
123
+ return nil unless File.file?(@state_path)
124
+
125
+ JSON.parse(File.read(@state_path))
126
+ rescue JSON::ParserError
127
+ nil
128
+ end
129
+
130
+ def load_thresholds
131
+ config = File.file?(@config_path) ? (YAML.safe_load(File.read(@config_path)) || {}) : {}
132
+ meter = config["meter"].is_a?(Hash) ? config["meter"] : {}
133
+ [
134
+ meter.fetch("reduce_at", DEFAULT_REDUCE_AT),
135
+ meter.fetch("stop_at", DEFAULT_STOP_AT),
136
+ meter.fetch("weekly_stop_at", DEFAULT_WEEKLY_STOP_AT),
137
+ ]
138
+ end
139
+
140
+ class << self
141
+ # Writes the LaunchAgent plist under an injectable home; never loads it
142
+ # with launchctl (scripts/meter-watch --install-timer calls this and
143
+ # nothing else). RunAtLoad primes the first tick; StartInterval repeats
144
+ # it every 20 minutes.
145
+ def install_timer(home:, script_path:, ruby: RbConfig.ruby, interval: TICK_SECONDS)
146
+ agents_dir = File.join(home, "Library", "LaunchAgents")
147
+ FileUtils.mkdir_p(agents_dir)
148
+ plist_path = File.join(agents_dir, "com.plastic.meter-watch.plist")
149
+ File.write(plist_path, plist(script_path, home, ruby, interval))
150
+ plist_path
151
+ end
152
+
153
+ private
154
+
155
+ def plist(script_path, home, ruby, interval)
156
+ <<~XML
157
+ <?xml version="1.0" encoding="UTF-8"?>
158
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
159
+ <plist version="1.0">
160
+ <dict>
161
+ <key>Label</key>
162
+ <string>com.plastic.meter-watch</string>
163
+ <key>ProgramArguments</key>
164
+ <array>
165
+ <string>#{ruby}</string>
166
+ <string>#{script_path}</string>
167
+ <string>--home</string>
168
+ <string>#{home}</string>
169
+ </array>
170
+ <key>StartInterval</key>
171
+ <integer>#{interval}</integer>
172
+ <key>RunAtLoad</key>
173
+ <true/>
174
+ </dict>
175
+ </plist>
176
+ XML
177
+ end
178
+ end
179
+ end
@@ -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