@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.
- package/package.json +2 -2
- package/scripts/dashboard.rb +20 -0
- package/scripts/doctor.rb +120 -2
- package/scripts/end-intent +134 -8
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/installer_core.rb +32 -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 +873 -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 +122 -12
- package/scripts/lib/roadmap_queue.rb +161 -3
- package/scripts/lib/roadmap_savepoint.rb +26 -5
- package/scripts/lib/savepoint.rb +123 -12
- 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/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +8 -1
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/validate-work-graph +39 -0
- package/skills/auto/SKILL.md +2 -3
- package/skills/auto/references/human-report-contract.md +3 -2
- package/skills/intent-continuing/references/boarding-matrix.md +1 -0
- package/skills/intent-ending/SKILL.md +30 -19
- package/skills/intent-executing/SKILL.md +1 -1
- 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/templates/graph.md +16 -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 +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
|