@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,231 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "worktree"
|
|
7
|
+
require_relative "lock"
|
|
8
|
+
require_relative "node_ledger"
|
|
9
|
+
require_relative "ready_set"
|
|
10
|
+
require_relative "savepoint"
|
|
11
|
+
|
|
12
|
+
# RunnerSweep (intent 340, G7, n2): the first thing every `step` does. Two
|
|
13
|
+
# separately callable entry points - #abort_if_merging and #reclaim - plus
|
|
14
|
+
# #run, which composes them for a caller that has no absorb step to interleave.
|
|
15
|
+
# `step` (a later node) calls #abort_if_merging, runs absorb, then calls
|
|
16
|
+
# #reclaim itself, so a reclaimed node's just-landed work from absorb is never
|
|
17
|
+
# thrown away by a stale read (matrix row 2.20).
|
|
18
|
+
#
|
|
19
|
+
# #abort_if_merging refuses the whole step when the intent worktree already
|
|
20
|
+
# has a merge in progress (`MERGE_HEAD` resolves): dispatching on top of a
|
|
21
|
+
# half-finished merge would hand the next node a diff full of someone else's
|
|
22
|
+
# conflict markers. Nothing is written when this fires - not the ledger, not
|
|
23
|
+
# graph.md, not even the delivery lease heartbeat (row 2.2, row 2.16: the
|
|
24
|
+
# heartbeat runs strictly after the abort check).
|
|
25
|
+
#
|
|
26
|
+
# #reclaim walks every node whose CURRENT status (the ledger's own resolution,
|
|
27
|
+
# never a raw scan) is `running`, skipping anything named in `skip:` (the
|
|
28
|
+
# nodes this step already absorbed, row 2.19) or already terminal (a `done`
|
|
29
|
+
# node is never touched, row 2.12 - it simply never shows up as `running`).
|
|
30
|
+
# An expired lease with no commits on the node's own branch newer than its
|
|
31
|
+
# expiry is reclaimed outright. An expired lease whose branch DOES carry
|
|
32
|
+
# newer commits is extended instead, up to twice per attempt (row 2.7); the
|
|
33
|
+
# extension is never a ledger transition (`running` cannot re-enter `running`
|
|
34
|
+
# under the transition layer), so it is one line appended to
|
|
35
|
+
# packets/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
|
|
36
|
+
# count (row 2.18), never trusted from the caller. A third expiry reclaims
|
|
37
|
+
# regardless of new commits.
|
|
38
|
+
#
|
|
39
|
+
# Pure and dependency-injected: every git call goes through an injected
|
|
40
|
+
# `runner:` (default Worktree::ShellRunner), never cwd; the delivery-lease
|
|
41
|
+
# heartbeat goes through an injected `heartbeat:` (default Lock.heartbeat) so
|
|
42
|
+
# ordering (row 2.16) is provable without a real lock file. No eval, no
|
|
43
|
+
# ENV/global-constant seam.
|
|
44
|
+
module RunnerSweep
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# D-ish: at most two extensions per attempt (row 2.7); the third expiry
|
|
48
|
+
# reclaims regardless of new commits.
|
|
49
|
+
MAX_EXTENSIONS_PER_ATTEMPT = 2
|
|
50
|
+
|
|
51
|
+
# abort_if_merging(context, runner:) -> {ok:, error:, recovery_command:}.
|
|
52
|
+
# `context.worktree` is nil for a global-store-only intent (no git repo to
|
|
53
|
+
# merge into); that case is always ok - there is nothing to abort.
|
|
54
|
+
def abort_if_merging(context, runner: Worktree::ShellRunner.new)
|
|
55
|
+
worktree = context&.worktree
|
|
56
|
+
return { ok: true, error: nil, recovery_command: nil } if blank?(worktree)
|
|
57
|
+
|
|
58
|
+
res = runner.run("-C", worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD")
|
|
59
|
+
return { ok: true, error: nil, recovery_command: nil } unless res.success?
|
|
60
|
+
|
|
61
|
+
recovery_command = "git -C #{worktree} merge --abort"
|
|
62
|
+
warn "runner: a merge is already in progress in #{worktree}; run `#{recovery_command}` " \
|
|
63
|
+
"before the next step can dispatch"
|
|
64
|
+
{ ok: false, error: "a merge is in progress in #{worktree}", recovery_command: recovery_command }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# reclaim(context, runner:, skip:, now:) -> {reclaimed: [{node:, holder:,
|
|
68
|
+
# expired:}], extended: [{node:, head:, time:}]}. Reads the ledger fresh on
|
|
69
|
+
# every call (no cache), which is what makes calling it AFTER absorb (row
|
|
70
|
+
# 2.20) actually see absorb's own just-landed work rather than a stale
|
|
71
|
+
# snapshot taken before it.
|
|
72
|
+
def reclaim(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now)
|
|
73
|
+
intent_dir = context.intent_dir
|
|
74
|
+
content = savepoint_content(intent_dir)
|
|
75
|
+
entries = NodeLedger.entries_from_content(content)
|
|
76
|
+
status_map = NodeLedger.status_from_content(content)
|
|
77
|
+
skip_set = Array(skip).map(&:to_s)
|
|
78
|
+
|
|
79
|
+
reclaimed = []
|
|
80
|
+
extended = []
|
|
81
|
+
|
|
82
|
+
status_map.each do |subject, state|
|
|
83
|
+
next unless state == "running"
|
|
84
|
+
next unless subject.match?(Savepoint::NODE_SUBJECT_RE)
|
|
85
|
+
next if skip_set.include?(subject)
|
|
86
|
+
|
|
87
|
+
last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
|
|
88
|
+
next unless last
|
|
89
|
+
|
|
90
|
+
fields = last[:fields] || {}
|
|
91
|
+
expires_raw = fields["expires"]
|
|
92
|
+
expires_at = parse_time(expires_raw)
|
|
93
|
+
next unless expires_at
|
|
94
|
+
next if now < expires_at # row 2.5: an unexpired lease is left alone
|
|
95
|
+
|
|
96
|
+
holder = fields["holder"]
|
|
97
|
+
branch = node_branch(context, subject)
|
|
98
|
+
head_sha, head_time = branch_head(runner, context.worktree, branch)
|
|
99
|
+
has_new_commits = head_time && head_time > expires_at
|
|
100
|
+
|
|
101
|
+
if has_new_commits
|
|
102
|
+
attempt = current_attempt(entries, subject)
|
|
103
|
+
count = extension_count(intent_dir, subject, attempt)
|
|
104
|
+
if count < MAX_EXTENSIONS_PER_ATTEMPT
|
|
105
|
+
record_extension(intent_dir, subject, attempt, head_sha, now)
|
|
106
|
+
extended << { node: subject, head: head_sha, time: now.utc.iso8601 }
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
# row 2.7: the cap is spent - fall through and reclaim anyway.
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
NodeLedger.append_transition(
|
|
113
|
+
savepoint_path(intent_dir),
|
|
114
|
+
subject: subject,
|
|
115
|
+
state: "reclaimed",
|
|
116
|
+
fields: { holder: holder, expired: expires_raw },
|
|
117
|
+
now: now
|
|
118
|
+
)
|
|
119
|
+
reclaimed << { node: subject, holder: holder, expired: expires_raw }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
{ reclaimed: reclaimed, extended: extended }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# run(context, runner:, skip:, now:, heartbeat:) -> the composed report a
|
|
126
|
+
# caller with no absorb step to interleave uses directly. Order is fixed on
|
|
127
|
+
# purpose (row 2.2, row 2.16): abort check, THEN the lease heartbeat, THEN
|
|
128
|
+
# reclaim - never the reverse, and never a write of any kind before the
|
|
129
|
+
# abort check has cleared.
|
|
130
|
+
def run(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now, heartbeat: Lock.method(:heartbeat))
|
|
131
|
+
abort_result = abort_if_merging(context, runner: runner)
|
|
132
|
+
unless abort_result[:ok]
|
|
133
|
+
return {
|
|
134
|
+
ok: false, aborted: true, error: abort_result[:error],
|
|
135
|
+
recovery_command: abort_result[:recovery_command], reclaimed: [], extended: [],
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
session = context&.session
|
|
140
|
+
heartbeat.call(context.intent_dir, session: session, now: now) unless blank?(session)
|
|
141
|
+
|
|
142
|
+
result = reclaim(context, runner: runner, skip: skip, now: now)
|
|
143
|
+
{
|
|
144
|
+
ok: true, aborted: false, error: nil, recovery_command: nil,
|
|
145
|
+
reclaimed: result[:reclaimed], extended: result[:extended],
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# --- internals ---------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def blank?(value)
|
|
152
|
+
value.nil? || value.to_s.strip.empty?
|
|
153
|
+
end
|
|
154
|
+
private_class_method :blank?
|
|
155
|
+
|
|
156
|
+
def savepoint_path(intent_dir)
|
|
157
|
+
File.join(intent_dir.to_s, "savepoint.md")
|
|
158
|
+
end
|
|
159
|
+
private_class_method :savepoint_path
|
|
160
|
+
|
|
161
|
+
def savepoint_content(intent_dir)
|
|
162
|
+
path = savepoint_path(intent_dir)
|
|
163
|
+
File.exist?(path) ? File.read(path) : ""
|
|
164
|
+
end
|
|
165
|
+
private_class_method :savepoint_content
|
|
166
|
+
|
|
167
|
+
# The node's own worktree branch (n3's naming: `plastic/<id>--<slug>--<node>`),
|
|
168
|
+
# derived from the intent id/slug alone - never through NodeWorktree, which
|
|
169
|
+
# this node does not depend on.
|
|
170
|
+
def node_branch(context, node)
|
|
171
|
+
"plastic/#{context.intent_id}--#{context.intent_slug}--#{node}"
|
|
172
|
+
end
|
|
173
|
+
private_class_method :node_branch
|
|
174
|
+
|
|
175
|
+
# [head_sha, committer_time] for `branch` in `worktree`'s repo, or [nil, nil]
|
|
176
|
+
# when the worktree is gone, the branch does not exist, or anything else
|
|
177
|
+
# about the git call fails (row 2.13: never raise).
|
|
178
|
+
def branch_head(runner, worktree, branch)
|
|
179
|
+
return [nil, nil] if blank?(worktree) || blank?(branch)
|
|
180
|
+
|
|
181
|
+
res = runner.run("-C", worktree, "log", "-1", "--format=%H%x1f%cI", branch)
|
|
182
|
+
return [nil, nil] unless res.success?
|
|
183
|
+
|
|
184
|
+
sha, iso = res.stdout.to_s.strip.split("\x1f")
|
|
185
|
+
[sha, parse_time(iso)]
|
|
186
|
+
rescue StandardError
|
|
187
|
+
[nil, nil]
|
|
188
|
+
end
|
|
189
|
+
private_class_method :branch_head
|
|
190
|
+
|
|
191
|
+
def parse_time(raw)
|
|
192
|
+
return nil if blank?(raw)
|
|
193
|
+
|
|
194
|
+
Time.iso8601(raw.to_s)
|
|
195
|
+
rescue ArgumentError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
private_class_method :parse_time
|
|
199
|
+
|
|
200
|
+
# Row 2.18: the attempt number comes from the ledger's own count of
|
|
201
|
+
# `running` lines since the subject's last terminal line - the exact same
|
|
202
|
+
# arithmetic NodePacket uses to name that attempt's packet file, so the
|
|
203
|
+
# extensions file for a `running` line always matches the packet it extends.
|
|
204
|
+
def current_attempt(entries, subject)
|
|
205
|
+
ReadySet.attempts_count(entries, subject)
|
|
206
|
+
end
|
|
207
|
+
private_class_method :current_attempt
|
|
208
|
+
|
|
209
|
+
def extensions_path(intent_dir, node, attempt)
|
|
210
|
+
File.join(intent_dir.to_s, "packets", "#{node}--a#{attempt}.extensions")
|
|
211
|
+
end
|
|
212
|
+
private_class_method :extensions_path
|
|
213
|
+
|
|
214
|
+
# Row 2.8: counted from the attempt-scoped file alone, so a prior attempt's
|
|
215
|
+
# extensions never count against a fresh dispatch.
|
|
216
|
+
def extension_count(intent_dir, node, attempt)
|
|
217
|
+
path = extensions_path(intent_dir, node, attempt)
|
|
218
|
+
return 0 unless File.exist?(path)
|
|
219
|
+
|
|
220
|
+
File.read(path).each_line.count { |l| !l.strip.empty? }
|
|
221
|
+
end
|
|
222
|
+
private_class_method :extension_count
|
|
223
|
+
|
|
224
|
+
# Row 2.9: the observed head sha and the time, one line, append-only.
|
|
225
|
+
def record_extension(intent_dir, node, attempt, head_sha, now)
|
|
226
|
+
path = extensions_path(intent_dir, node, attempt)
|
|
227
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
228
|
+
File.open(path, "a") { |f| f.write("#{now.utc.iso8601} head=#{head_sha}\n") }
|
|
229
|
+
end
|
|
230
|
+
private_class_method :record_extension
|
|
231
|
+
end
|
package/scripts/lib/savepoint.rb
CHANGED
|
@@ -23,6 +23,36 @@ module Savepoint
|
|
|
23
23
|
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
24
24
|
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
25
25
|
|
|
26
|
+
# --- Node-graph transition subject vocabulary (intent 335, spec D17) -------
|
|
27
|
+
#
|
|
28
|
+
# Owned HERE, not on NodeLedger, because test/savepoint_split_test.rb:57 pins
|
|
29
|
+
# savepoint.rb to loading no other project file: NodeLedger requires this
|
|
30
|
+
# file and reuses these three names rather than duplicating them, so the
|
|
31
|
+
# dependency runs one way only. Intent 334 (G1) mints node ids and must
|
|
32
|
+
# agree with NODE_SUBJECT_RE: it is the single seam for the node id shape.
|
|
33
|
+
|
|
34
|
+
# The literal subject token for an intent-scope transition line ("Intent
|
|
35
|
+
# needs_decision question=..."), as opposed to a node-scope line.
|
|
36
|
+
INTENT_SUBJECT = "Intent"
|
|
37
|
+
|
|
38
|
+
# A node id: one or two lowercase letters (the node's kind prefix, e.g. "n"
|
|
39
|
+
# for work, "v" for verify) followed by digits.
|
|
40
|
+
NODE_SUBJECT_RE = /\A[a-z]{1,2}\d+\z/.freeze
|
|
41
|
+
|
|
42
|
+
# True iff a raw savepoint ledger line's subject (field 2, split on
|
|
43
|
+
# /\s{2,}/) is a transition candidate: the literal Intent token or a node
|
|
44
|
+
# id. A stage line ("How checklist.md created") or a Lock takeover audit
|
|
45
|
+
# line never matches, by construction (spec Acceptance Criteria: "no stage
|
|
46
|
+
# token this tree writes collides with Intent or with the node id
|
|
47
|
+
# pattern").
|
|
48
|
+
def self.transition_candidate?(line)
|
|
49
|
+
parts = line.to_s.split(/\s{2,}/)
|
|
50
|
+
return false unless parts.length >= 2
|
|
51
|
+
|
|
52
|
+
subject = parts[1]
|
|
53
|
+
subject == INTENT_SUBJECT || subject.match?(NODE_SUBJECT_RE)
|
|
54
|
+
end
|
|
55
|
+
|
|
26
56
|
def self.intent_file(intent_dir)
|
|
27
57
|
dir_name = File.basename(intent_dir)
|
|
28
58
|
"#{intent_dir}/#{dir_name}.md"
|
|
@@ -57,22 +87,47 @@ module Savepoint
|
|
|
57
87
|
File.exist?(path)
|
|
58
88
|
end
|
|
59
89
|
|
|
60
|
-
# True iff actions/ holds AT LEAST ONE real
|
|
61
|
-
# first line
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# reports it needs a real action file); it never raises.
|
|
66
|
-
def self.has_real_action?(intent_dir)
|
|
67
|
-
Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
|
|
90
|
+
# True iff DIR_NAME (actions/ or nodes/) holds AT LEAST ONE real *.md file: non-empty,
|
|
91
|
+
# first line not the placeholder sentinel. A `.gitkeep` (no .md extension) never counts.
|
|
92
|
+
# Pure and side-effect-free; fail-open (a missing dir globs to nothing, never raises).
|
|
93
|
+
def self.has_real_files_in?(dir_name, intent_dir)
|
|
94
|
+
Dir.glob("#{intent_dir}/#{dir_name}/*.md").any? do |f|
|
|
68
95
|
File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
|
|
69
96
|
end
|
|
70
97
|
rescue StandardError
|
|
71
98
|
false
|
|
72
99
|
end
|
|
73
100
|
|
|
101
|
+
# True iff the intent has at least one real action file, whether delivered as
|
|
102
|
+
# legacy actions/*.md or as a node graph's nodes/*.md (intent 334, G1, D10r):
|
|
103
|
+
# an intent delivered as nodes is exactly as real as one delivered as
|
|
104
|
+
# actions, so doctor and the exec-stage gate never report a backfill gap on
|
|
105
|
+
# a fully delivered node-graph intent. Checks actions/ first (the common
|
|
106
|
+
# path today), falling through to nodes/ only when actions/ has nothing.
|
|
107
|
+
def self.has_real_action?(intent_dir)
|
|
108
|
+
has_real_files_in?("actions", intent_dir) || has_real_files_in?("nodes", intent_dir)
|
|
109
|
+
rescue StandardError
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Intent 336 (G3, D13): a real graph.md is 327 D41's replacement for
|
|
114
|
+
# plan.md/checklist.md on the common path, so a node-shaped intent is
|
|
115
|
+
# judged on graph.md and nodes/ alone, whether or not spec.md is real - the
|
|
116
|
+
# graph IS the spec under D41. Checked using only this file's own
|
|
117
|
+
# primitives (stage_file_present?, has_real_files_in?), because
|
|
118
|
+
# test/savepoint_split_test.rb pins this file to loading no other project
|
|
119
|
+
# file and no YAML: GraphFile/NodeFile/ReadySet are never required here. An
|
|
120
|
+
# intent with no real graph.md (or a sentinel-placeholder one,
|
|
121
|
+
# stage_file_present? already reads that as absent) derives exactly what it
|
|
122
|
+
# derived before this intent.
|
|
74
123
|
def self.derive_stage(intent_dir)
|
|
75
124
|
return "done" if stage_file_present?("#{intent_dir}/outcome.md")
|
|
125
|
+
|
|
126
|
+
if stage_file_present?("#{intent_dir}/graph.md")
|
|
127
|
+
return "exec" if has_real_files_in?("nodes", intent_dir)
|
|
128
|
+
return "how"
|
|
129
|
+
end
|
|
130
|
+
|
|
76
131
|
if stage_file_present?("#{intent_dir}/plan.md") &&
|
|
77
132
|
has_real_action?(intent_dir) &&
|
|
78
133
|
stage_file_present?("#{intent_dir}/checklist.md")
|
|
@@ -87,19 +142,49 @@ module Savepoint
|
|
|
87
142
|
files = []
|
|
88
143
|
ifile = File.basename(intent_file(intent_dir))
|
|
89
144
|
files << ifile if File.exist?("#{intent_dir}/#{ifile}")
|
|
90
|
-
["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
145
|
+
["spec.md", "graph.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
91
146
|
files << f if stage_file_present?("#{intent_dir}/#{f}")
|
|
92
147
|
end
|
|
93
|
-
|
|
148
|
+
# Name the directory that actually exists (fold B3): a nodes-only intent
|
|
149
|
+
# must never claim the literal "actions/" artifact it does not have.
|
|
150
|
+
# Checks actions/ first, matching D15r's read order.
|
|
151
|
+
if has_real_files_in?("actions", intent_dir)
|
|
152
|
+
files << "actions/"
|
|
153
|
+
elsif has_real_files_in?("nodes", intent_dir)
|
|
154
|
+
files << "nodes/"
|
|
155
|
+
end
|
|
94
156
|
files
|
|
95
157
|
end
|
|
96
158
|
|
|
97
159
|
def self.missing_for_stage(stage, intent_dir = nil)
|
|
98
160
|
ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
|
|
161
|
+
# A How-stage intent that already started a nodes/ directory is named
|
|
162
|
+
# accordingly, so the next-step hint never tells a node-graph intent to
|
|
163
|
+
# go make an actions/ directory it will never use (fold B3). Mirrors
|
|
164
|
+
# has_real_files_in?'s actions-first order and its real-file requirement
|
|
165
|
+
# (post-execution review, non-blocking 4): an intent carrying real files
|
|
166
|
+
# in both directories, or a real actions/ file beside an empty or
|
|
167
|
+
# .gitkeep-only nodes/, is named actions/, never nodes/.
|
|
168
|
+
action_label = if intent_dir && has_real_files_in?("actions", intent_dir)
|
|
169
|
+
"actions/"
|
|
170
|
+
elsif intent_dir && has_real_files_in?("nodes", intent_dir)
|
|
171
|
+
"nodes/"
|
|
172
|
+
else
|
|
173
|
+
"actions/"
|
|
174
|
+
end
|
|
99
175
|
case stage
|
|
100
176
|
when "what" then [ifile]
|
|
101
177
|
when "why" then ["spec.md"]
|
|
102
|
-
when "how"
|
|
178
|
+
when "how"
|
|
179
|
+
# A node-shaped How intent (a real graph.md already) is named after its
|
|
180
|
+
# own two artifacts, never the three D41 removed from its path (fold
|
|
181
|
+
# B3, extended by 336 D13): checked via stage_file_present? alone, the
|
|
182
|
+
# same primitive derive_stage itself uses.
|
|
183
|
+
if intent_dir && stage_file_present?("#{intent_dir}/graph.md")
|
|
184
|
+
["graph.md", "nodes/"]
|
|
185
|
+
else
|
|
186
|
+
["plan.md", action_label, "checklist.md"]
|
|
187
|
+
end
|
|
103
188
|
when "exec" then ["outcome.md"]
|
|
104
189
|
else []
|
|
105
190
|
end
|
|
@@ -213,6 +298,32 @@ module Savepoint
|
|
|
213
298
|
append_savepoint_line(intent_dir, "Exec", "started", now)
|
|
214
299
|
end
|
|
215
300
|
|
|
301
|
+
# Append a `Review` line: one per plan-review or post-execution-review verdict
|
|
302
|
+
# (intent 317, D5/D17). Same shape as every other line, through the shared
|
|
303
|
+
# append_savepoint_line primitive, so dedup and the timestamp format never
|
|
304
|
+
# drift from the one line-writer every other kind already uses.
|
|
305
|
+
def self.append_review_savepoint(intent_dir, text, now: Time.now)
|
|
306
|
+
append_savepoint_line(intent_dir, "Review", text, now)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Append a `Commit` line: one per commit landing during Exec (intent 317,
|
|
310
|
+
# D5/D17). Same primitive as append_review_savepoint above.
|
|
311
|
+
def self.append_commit_savepoint(intent_dir, text, now: Time.now)
|
|
312
|
+
append_savepoint_line(intent_dir, "Commit", text, now)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The day the Report kind (below) shipped. `doctor`'s intent_reports_printed_check reads
|
|
316
|
+
# this so it never re-litigates a ledger recorded before the kind existed (intent 331f, R6).
|
|
317
|
+
REPORT_KIND_SINCE = "2026-09-05"
|
|
318
|
+
|
|
319
|
+
# Append a `Report` line: one per report screen printed (intent 331f, D2/D3). Same primitive
|
|
320
|
+
# as append_review_savepoint/append_commit_savepoint above, so the line shape, dedup, and
|
|
321
|
+
# timestamp format never drift. `savepoint_milestone` maps FILENAMES only, so a Report line
|
|
322
|
+
# is never mistaken for a file-landing lifecycle line by construction.
|
|
323
|
+
def self.append_report_savepoint(intent_dir, text, now: Time.now)
|
|
324
|
+
append_savepoint_line(intent_dir, "Report", text, now)
|
|
325
|
+
end
|
|
326
|
+
|
|
216
327
|
TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
|
|
217
328
|
|
|
218
329
|
# Append the terminal bookend `Done delivered|abandoned`, written by the
|
|
@@ -232,6 +343,17 @@ module Savepoint
|
|
|
232
343
|
# A Plastic 1.x ledger may carry a `Tier <value>` line after the spec.md
|
|
233
344
|
# milestone (removed in 2.0, intent 304); a rebuild drops it, and the phantom
|
|
234
345
|
# detector ignores it, so a 1.x store reads clean.
|
|
346
|
+
#
|
|
347
|
+
# Every transition line (intent 335, spec D13) is preserved VERBATIM, in its
|
|
348
|
+
# original relative order, after the reconstructed stage skeleton. It is
|
|
349
|
+
# never dropped and never refused: a transition line's evidence fields
|
|
350
|
+
# (`holder=`, `expires=`, `gates=`, ...) have no file-mtime analog to
|
|
351
|
+
# reconstruct from, so refusing instead of preserving would make this method
|
|
352
|
+
# destroy the graph's only status on every intent that carries one. Relative
|
|
353
|
+
# order BETWEEN a stage line and a transition line is not preserved (safe:
|
|
354
|
+
# status is computed per subject, and the two families share no subject);
|
|
355
|
+
# relative order WITHIN the transition lines is preserved, which is what
|
|
356
|
+
# "last line per subject in file order" depends on.
|
|
235
357
|
def self.rebuild_savepoint(intent_dir)
|
|
236
358
|
ordered = [
|
|
237
359
|
File.basename(intent_file(intent_dir)),
|
|
@@ -245,7 +367,18 @@ module Savepoint
|
|
|
245
367
|
stamp = File.mtime(path).utc.iso8601
|
|
246
368
|
["#{stamp} #{stage} #{milestone}\n"]
|
|
247
369
|
end
|
|
248
|
-
|
|
370
|
+
|
|
371
|
+
savepoint_path = File.join(intent_dir, SAVEPOINT_FILE)
|
|
372
|
+
if File.exist?(savepoint_path)
|
|
373
|
+
# #scrub before scanning (post-execution review row 7.8), the same way
|
|
374
|
+
# NodeLedger.entries does (matrix 2.44): a stray non-UTF-8 byte anywhere
|
|
375
|
+
# in the ledger must not raise out of the one repair tool three doctor
|
|
376
|
+
# fix hints and maintenance-run --tool rebuild-savepoint point at.
|
|
377
|
+
transition_lines = File.read(savepoint_path).scrub.each_line.select { |raw| transition_candidate?(raw) }
|
|
378
|
+
lines += transition_lines.map { |raw| raw.end_with?("\n") ? raw : "#{raw}\n" }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
File.write(savepoint_path, lines.join)
|
|
249
382
|
lines.length
|
|
250
383
|
end
|
|
251
384
|
|
|
@@ -294,6 +427,10 @@ module Savepoint
|
|
|
294
427
|
File.read(path).each_line do |raw|
|
|
295
428
|
line = raw.strip
|
|
296
429
|
next if line.empty?
|
|
430
|
+
# A transition line (intent 335) is never a stage phantom candidate: its
|
|
431
|
+
# own repeated-line semantics (dedup-free by design, spec D11) are
|
|
432
|
+
# NodeLedger's concern, not this detector's.
|
|
433
|
+
next if transition_candidate?(line)
|
|
297
434
|
parts = line.split(/\s{2,}/)
|
|
298
435
|
next if parts.length < 3
|
|
299
436
|
pair = [parts[1], parts[2]]
|