@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -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-call-budget +222 -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 +45 -7
- 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/compact_instructions.rb +5 -5
- 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 +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -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 +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- 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/meter_watch.rb +179 -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 +908 -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 +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -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/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -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/read-config +3 -3
- 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 +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -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 +41 -34
- 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 +21 -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/config.yml +3 -3
- 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,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
|