@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20
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/hooks/hooks.json +12 -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-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- 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/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 +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -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 +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- 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/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 +886 -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 +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -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/verify_intent.rb +33 -0
- 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/plastic-lock +8 -1
- 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 +392 -0
- package/scripts/savepoint-note +69 -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 +24 -17
- 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 +17 -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/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
|
@@ -27,9 +27,10 @@ class Doctor
|
|
|
27
27
|
"hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
|
|
28
28
|
}.freeze
|
|
29
29
|
|
|
30
|
-
# The Claude events hooks_registered expects in settings.json: the
|
|
31
|
-
# cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301
|
|
32
|
-
|
|
30
|
+
# The Claude events hooks_registered expects in settings.json: the six-event map of
|
|
31
|
+
# cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
|
|
32
|
+
# intent 316a added MessageDisplay, registered for message-display, Claude only).
|
|
33
|
+
CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd MessageDisplay].freeze
|
|
33
34
|
|
|
34
35
|
# Launchers the installer places in the agent's hooks dir that are NOT hooks
|
|
35
36
|
# (intent 204): plastic-statusline is the settings["statusLine"] command, wired
|
|
@@ -90,8 +91,19 @@ class Doctor
|
|
|
90
91
|
|
|
91
92
|
JSON.parse(File.read(path))
|
|
92
93
|
rescue JSON::ParserError
|
|
93
|
-
|
|
94
|
-
JSON
|
|
94
|
+
# The comment/trailing-comma-stripped retry below can itself raise
|
|
95
|
+
# JSON::ParserError on genuinely malformed content (a truncated file, or
|
|
96
|
+
# plain garbage). A nested begin/rescue is required here because a
|
|
97
|
+
# method-level `rescue` clause never catches an exception raised from
|
|
98
|
+
# INSIDE a sibling rescue clause's own body (only from the main body).
|
|
99
|
+
# Without this nesting a malformed settings.json crashes doctor instead
|
|
100
|
+
# of reporting a clean fail (intent 331e, F5).
|
|
101
|
+
begin
|
|
102
|
+
content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
|
|
103
|
+
JSON.parse(content)
|
|
104
|
+
rescue
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
95
107
|
rescue
|
|
96
108
|
nil
|
|
97
109
|
end
|
|
@@ -1244,10 +1256,94 @@ class Doctor
|
|
|
1244
1256
|
all_checks += check_manifest_sync(agent_key)
|
|
1245
1257
|
all_checks += check_registered_project_paths
|
|
1246
1258
|
all_checks += check_global_store_available
|
|
1259
|
+
all_checks += check_display_registration(agent_key)
|
|
1247
1260
|
|
|
1248
1261
|
summarize(all_checks, agent_key, binary: true)
|
|
1249
1262
|
end
|
|
1250
1263
|
|
|
1264
|
+
# The single `hooks/<name>` launcher basename for the MessageDisplay event,
|
|
1265
|
+
# derived from HookRegistry rather than hand-kept (intent 331e), so a
|
|
1266
|
+
# future rename of the hook stays in one place. Shared by
|
|
1267
|
+
# check_display_registration (below, boot path) and scripts/doctor.rb's
|
|
1268
|
+
# check_display_paints (full run), which resolves the SAME name under the
|
|
1269
|
+
# agent_dir it was given.
|
|
1270
|
+
def display_hook_launcher_name
|
|
1271
|
+
group = HookRegistry.events["MessageDisplay"].first
|
|
1272
|
+
"plastic-#{group['hooks'].first['name']}"
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
DISPLAY_HOOK_FIX_HINT = "Re-run the Plastic installer to repair the hook registration: " \
|
|
1276
|
+
"npx @zalom/plastic@<channel> install --reinstall --claude " \
|
|
1277
|
+
"(plastic-install --repair)".freeze
|
|
1278
|
+
|
|
1279
|
+
# display_hook_registered (intent 331e, D1, category "display"): the Claude
|
|
1280
|
+
# settings carry the plastic-message-display command, on-disk, executable.
|
|
1281
|
+
# Boot-path safe: resolves everything from the injected `agents` hash and
|
|
1282
|
+
# `plastic_home`, never Dir.home or a real ~/.claude (E18). This is the same
|
|
1283
|
+
# discipline check_claude_registration already follows.
|
|
1284
|
+
#
|
|
1285
|
+
# D3: a harness Doctor knows carries no display hook (Codex, Hermes) is a
|
|
1286
|
+
# pass, not a fail, worded "plain by contract" like the paint check's own
|
|
1287
|
+
# skip (scripts/doctor.rb's check_display_paints).
|
|
1288
|
+
def check_display_registration(agent_key)
|
|
1289
|
+
config = agents[agent_key]
|
|
1290
|
+
unless agent_key == "claude"
|
|
1291
|
+
return [check(
|
|
1292
|
+
category: "display", name: "display_hook_registered", status: "pass",
|
|
1293
|
+
message: "#{config[:name]} is plain by contract; no MessageDisplay hook to register"
|
|
1294
|
+
)]
|
|
1295
|
+
end
|
|
1296
|
+
|
|
1297
|
+
agent_dir = config[:dir]
|
|
1298
|
+
settings_path = File.join(agent_dir, "settings.json")
|
|
1299
|
+
settings = read_json_safe(settings_path)
|
|
1300
|
+
|
|
1301
|
+
if settings.nil?
|
|
1302
|
+
return [check(
|
|
1303
|
+
category: "display", name: "display_hook_registered", status: "fail",
|
|
1304
|
+
message: "Cannot read #{tilde(settings_path)}: file missing or invalid",
|
|
1305
|
+
fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
|
|
1306
|
+
)]
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
hooks = settings["hooks"].is_a?(Hash) ? settings["hooks"] : {}
|
|
1310
|
+
commands = event_commands(hooks["MessageDisplay"])
|
|
1311
|
+
launcher_name = display_hook_launcher_name
|
|
1312
|
+
|
|
1313
|
+
registered = commands.any? { |cmd| HookRegistry.command_basenames(cmd).include?(launcher_name) }
|
|
1314
|
+
|
|
1315
|
+
unless registered
|
|
1316
|
+
return [check(
|
|
1317
|
+
category: "display", name: "display_hook_registered", status: "fail",
|
|
1318
|
+
message: "No MessageDisplay hook registered in #{tilde(settings_path)}",
|
|
1319
|
+
fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
|
|
1320
|
+
)]
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
launcher_path = File.join(agent_dir, "hooks", launcher_name)
|
|
1324
|
+
|
|
1325
|
+
unless File.exist?(launcher_path)
|
|
1326
|
+
return [check(
|
|
1327
|
+
category: "display", name: "display_hook_registered", status: "fail",
|
|
1328
|
+
message: "MessageDisplay is registered but #{tilde(launcher_path)} does not exist",
|
|
1329
|
+
fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
|
|
1330
|
+
)]
|
|
1331
|
+
end
|
|
1332
|
+
|
|
1333
|
+
unless File.executable?(launcher_path)
|
|
1334
|
+
return [check(
|
|
1335
|
+
category: "display", name: "display_hook_registered", status: "fail",
|
|
1336
|
+
message: "#{tilde(launcher_path)} exists but is not executable",
|
|
1337
|
+
fixable: true, fix_hint: DISPLAY_HOOK_FIX_HINT
|
|
1338
|
+
)]
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
[check(
|
|
1342
|
+
category: "display", name: "display_hook_registered", status: "pass",
|
|
1343
|
+
message: "MessageDisplay hook registered and #{tilde(launcher_path)} is executable"
|
|
1344
|
+
)]
|
|
1345
|
+
end
|
|
1346
|
+
|
|
1251
1347
|
def check_registered_project_paths
|
|
1252
1348
|
checks = []
|
|
1253
1349
|
|
|
@@ -18,6 +18,18 @@ require_relative "session_ledger"
|
|
|
18
18
|
module DoctorSessionLedger
|
|
19
19
|
ORPHAN_TTL_SECONDS = 24 * 60 * 60
|
|
20
20
|
|
|
21
|
+
# Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
|
|
22
|
+
# DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
|
|
23
|
+
# session start never ran at all (a `-p` print session, a resumed
|
|
24
|
+
# background job, an unregistered SessionStart hook), not a session that
|
|
25
|
+
# ran and then never closed. No choice of session id ever creates a
|
|
26
|
+
# pointer for that class, so it would otherwise stay invisible for the
|
|
27
|
+
# full 24 hours (or forever, once hook-capture/hook-record stop creating
|
|
28
|
+
# it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
|
|
29
|
+
# between its own start and its first pointer write is normal and must not
|
|
30
|
+
# be flagged, but that window is seconds, not hours.
|
|
31
|
+
NO_POINTER_TTL_SECONDS = 5 * 60
|
|
32
|
+
|
|
21
33
|
# Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
|
|
22
34
|
# else that file's mtime, else the directory's mtime.
|
|
23
35
|
def heartbeat_age(dir, now)
|
|
@@ -37,6 +49,7 @@ module DoctorSessionLedger
|
|
|
37
49
|
|
|
38
50
|
store_dir = File.join(plastic_home, "store")
|
|
39
51
|
orphans = orphaned_session_dirs(store_dir, now)
|
|
52
|
+
no_pointer = no_pointer_session_dirs(store_dir, now)
|
|
40
53
|
shape = day_ledger_shape_problems(store_dir)
|
|
41
54
|
|
|
42
55
|
checks = []
|
|
@@ -54,6 +67,20 @@ module DoctorSessionLedger
|
|
|
54
67
|
"session is gone: a live session rewrites its heartbeat on every " \
|
|
55
68
|
"prompt and edit, so only a listed directory may be removed, by hand.")
|
|
56
69
|
end
|
|
70
|
+
checks << if no_pointer.empty?
|
|
71
|
+
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
|
|
72
|
+
message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
|
|
73
|
+
"longer than #{NO_POINTER_TTL_SECONDS} seconds")
|
|
74
|
+
else
|
|
75
|
+
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
|
|
76
|
+
message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
|
|
77
|
+
"with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
|
|
78
|
+
"(session start never ran for this session)",
|
|
79
|
+
details: no_pointer, fixable: true,
|
|
80
|
+
fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
|
|
81
|
+
"session never started: no `current` pointer means session start never " \
|
|
82
|
+
"ran for it, so this is not a live session missing a checklist entry.")
|
|
83
|
+
end
|
|
57
84
|
checks << if shape.empty?
|
|
58
85
|
check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
|
|
59
86
|
message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
|
|
@@ -86,6 +113,31 @@ module DoctorSessionLedger
|
|
|
86
113
|
[] # unreadable .tmp/: nothing to report, never a crash
|
|
87
114
|
end
|
|
88
115
|
|
|
116
|
+
# Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
|
|
117
|
+
# NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
|
|
118
|
+
# that one is age-only and blind to whether a pointer exists at all, so a
|
|
119
|
+
# young no-pointer dir (a session between its start and its first pointer
|
|
120
|
+
# write) must not appear here even though it may well appear there once it
|
|
121
|
+
# ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
|
|
122
|
+
# and a dir can legitimately show up in neither, either, or both.
|
|
123
|
+
def no_pointer_session_dirs(store_dir, now)
|
|
124
|
+
tmp_root = SessionLedger.tmp_root(store_dir)
|
|
125
|
+
return [] unless File.directory?(tmp_root)
|
|
126
|
+
|
|
127
|
+
Dir.children(tmp_root).sort.filter_map do |name|
|
|
128
|
+
dir = File.join(tmp_root, name)
|
|
129
|
+
next unless File.directory?(dir)
|
|
130
|
+
next if File.exist?(File.join(dir, "current"))
|
|
131
|
+
|
|
132
|
+
age = heartbeat_age(dir, now)
|
|
133
|
+
next if age <= NO_POINTER_TTL_SECONDS
|
|
134
|
+
|
|
135
|
+
"global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
|
|
136
|
+
end
|
|
137
|
+
rescue SystemCallError
|
|
138
|
+
[] # unreadable .tmp/: nothing to report, never a crash
|
|
139
|
+
end
|
|
140
|
+
|
|
89
141
|
def day_ledger_shape_problems(store_dir)
|
|
90
142
|
root = SessionLedger.sessions_root(store_dir)
|
|
91
143
|
return [] unless File.directory?(root)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# GraphEdges (intent 334, n1): the shared `needs` syntax and cycle-path check
|
|
5
|
+
# (327 D2r-D4r, D12r). Parses a "## Graph" section's edge lines into a node
|
|
6
|
+
# set and an edge map, the one parser an intent's graph.md and a roadmap's
|
|
7
|
+
# own ## Graph section both use unchanged (C1, fold A5): the same list-item
|
|
8
|
+
# grammar, the same root keyword, the same cycle walk. Deliberately loose
|
|
9
|
+
# about what an id looks like - a roadmap id is numeric ("334", "340a"), a
|
|
10
|
+
# node id carries a kind prefix ("n1") - the kind-prefix rule belongs to
|
|
11
|
+
# NodeFile and WorkGraphValidator, never here (D12r, fold A6).
|
|
12
|
+
#
|
|
13
|
+
# Grammar (D2r/D3r): a list item shaped "- <id> needs <target> [<target>
|
|
14
|
+
# ...]", ending at end of line. The single literal target "nothing" declares
|
|
15
|
+
# a root and must be the ONLY target on the line. Any line that does not
|
|
16
|
+
# start with "- <token> needs" is prose and is skipped in silence. A line
|
|
17
|
+
# that DOES look like an edge is held to the grammar strictly: no target at
|
|
18
|
+
# all, "nothing" alongside other targets, or a target token carrying
|
|
19
|
+
# sentence punctuation (a period, a colon, ...) rather than the loose
|
|
20
|
+
# id-token charset, is an error naming the line, never a silently
|
|
21
|
+
# mis-parsed edge or an invented node (fold A7).
|
|
22
|
+
#
|
|
23
|
+
# Pure and side-effect-free; never raises across the boundary, matching the
|
|
24
|
+
# Result-hash convention every library in scripts/lib/ follows.
|
|
25
|
+
module GraphEdges
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
EDGE_LEAD_RE = /\A-\s*(\S+)\s+needs\b(.*)\z/.freeze
|
|
29
|
+
TOKEN_RE = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
|
|
30
|
+
ROOT_TARGET = "nothing"
|
|
31
|
+
|
|
32
|
+
# {nodes:, edges:, errors:} - nodes is the declared ids together with every
|
|
33
|
+
# id any edge targets (fold A5), in first-seen order; edges maps a declared
|
|
34
|
+
# id to its target ids (empty for a root); errors names each malformed
|
|
35
|
+
# edge-shaped line. Prose lines contribute nothing and raise nothing.
|
|
36
|
+
def parse(section_text)
|
|
37
|
+
declared_order = []
|
|
38
|
+
edges = {}
|
|
39
|
+
targeted = []
|
|
40
|
+
errors = []
|
|
41
|
+
|
|
42
|
+
section_text.to_s.each_line do |raw_line|
|
|
43
|
+
line = raw_line.chomp("\n").chomp("\r")
|
|
44
|
+
stripped = line.strip
|
|
45
|
+
next if stripped.empty?
|
|
46
|
+
|
|
47
|
+
m = stripped.match(EDGE_LEAD_RE)
|
|
48
|
+
next unless m
|
|
49
|
+
|
|
50
|
+
id = m[1]
|
|
51
|
+
raw_targets = m[2].to_s.strip.split(/\s+/)
|
|
52
|
+
|
|
53
|
+
if raw_targets.empty?
|
|
54
|
+
errors << "malformed edge line, no target: #{stripped.inspect}"
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if raw_targets.include?(ROOT_TARGET) && raw_targets.length > 1
|
|
59
|
+
errors << "root target #{ROOT_TARGET.inspect} mixed with a real target, root must be the only target: #{stripped.inspect}"
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
bad_token = raw_targets.find { |t| t != ROOT_TARGET && !t.match?(TOKEN_RE) }
|
|
64
|
+
if bad_token
|
|
65
|
+
errors << "prose tail after targets: #{stripped.inspect}"
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if edges.key?(id)
|
|
70
|
+
errors << "duplicate node declaration for #{id.inspect}: #{stripped.inspect}"
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
targets = raw_targets == [ROOT_TARGET] ? [] : raw_targets
|
|
75
|
+
declared_order << id
|
|
76
|
+
edges[id] = targets
|
|
77
|
+
targeted.concat(targets)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
nodes = declared_order.dup
|
|
81
|
+
targeted.each { |t| nodes << t unless nodes.include?(t) }
|
|
82
|
+
|
|
83
|
+
{ nodes: nodes, edges: edges, errors: errors }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The cycle as a path with the entry id repeated (D4r), or nil when the
|
|
87
|
+
# graph is acyclic. A node reachable by two distinct paths (a diamond) is
|
|
88
|
+
# never mistaken for a cycle: once a node's whole subtree is walked clean
|
|
89
|
+
# it is marked visited and never re-examined.
|
|
90
|
+
def cycle(edges)
|
|
91
|
+
visiting = {}
|
|
92
|
+
visited = {}
|
|
93
|
+
path = []
|
|
94
|
+
|
|
95
|
+
edges.each_key do |node|
|
|
96
|
+
next if visited[node]
|
|
97
|
+
found = walk(node, edges, visiting, visited, path)
|
|
98
|
+
return found if found
|
|
99
|
+
end
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def walk(node, edges, visiting, visited, path)
|
|
104
|
+
if visiting[node]
|
|
105
|
+
idx = path.index(node)
|
|
106
|
+
return path[idx..] + [node]
|
|
107
|
+
end
|
|
108
|
+
return nil if visited[node]
|
|
109
|
+
|
|
110
|
+
visiting[node] = true
|
|
111
|
+
path.push(node)
|
|
112
|
+
(edges[node] || []).each do |target|
|
|
113
|
+
found = walk(target, edges, visiting, visited, path)
|
|
114
|
+
return found if found
|
|
115
|
+
end
|
|
116
|
+
path.pop
|
|
117
|
+
visiting.delete(node)
|
|
118
|
+
visited[node] = true
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_edges"
|
|
5
|
+
require_relative "atomic_write"
|
|
6
|
+
|
|
7
|
+
# GraphFile (intent 334, n2): the four graph.md sections (## Goal,
|
|
8
|
+
# ## Decisions, ## Graph, ## Status), the verify:-none directive, and the two
|
|
9
|
+
# writers - write_status and append_decision. Both writers refuse a cyclic
|
|
10
|
+
# graph and both go through AtomicWrite (fold D19r). Fence-aware everywhere a
|
|
11
|
+
# heading is located, so a fenced example carrying a fake "## " line never
|
|
12
|
+
# splits or ends a real section (fold A8's sibling concern, applied to
|
|
13
|
+
# section boundaries rather than edge lines).
|
|
14
|
+
module GraphFile
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
|
|
18
|
+
DIRECTIVE_RE = /\A-\s*verify:\s*none(?:\s+reason=(.*))?\z/i.freeze
|
|
19
|
+
|
|
20
|
+
# {ok:, goal:, decisions:, graph:, status:, verify:, errors:}. `graph` is
|
|
21
|
+
# GraphEdges.parse's own Result hash, over the Graph section text with any
|
|
22
|
+
# verify:-none directive line stripped first (fold A8). A missing ## Graph
|
|
23
|
+
# section, or one that declares no nodes, is an error naming which (fold
|
|
24
|
+
# A10).
|
|
25
|
+
def parse(path)
|
|
26
|
+
return failure(["graph file not found: #{path}"]) unless File.exist?(path)
|
|
27
|
+
|
|
28
|
+
content = File.read(path)
|
|
29
|
+
goal = section_body(content, "## Goal")
|
|
30
|
+
decisions = section_body(content, "## Decisions")
|
|
31
|
+
graph_section = section_body(content, "## Graph")
|
|
32
|
+
status = section_body(content, "## Status")
|
|
33
|
+
|
|
34
|
+
errors = []
|
|
35
|
+
if graph_section.nil?
|
|
36
|
+
errors << "missing ## Graph section"
|
|
37
|
+
return { ok: false, goal: goal, decisions: decisions, graph: nil, status: status, verify: nil, errors: errors }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
directive, directive_errors, remaining = extract_verify_directive(graph_section)
|
|
41
|
+
errors.concat(directive_errors)
|
|
42
|
+
|
|
43
|
+
parsed_graph = GraphEdges.parse(strip_fenced_blocks(remaining))
|
|
44
|
+
errors.concat(parsed_graph[:errors])
|
|
45
|
+
errors << "## Graph declares no nodes" if parsed_graph[:nodes].empty?
|
|
46
|
+
|
|
47
|
+
{ ok: errors.empty?, goal: goal, decisions: decisions, graph: parsed_graph, status: status, verify: directive, errors: errors }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def failure(errors)
|
|
51
|
+
{ ok: false, goal: nil, decisions: nil, graph: nil, status: nil, verify: nil, errors: errors }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rows shaped {node:, state:, detail:} parsed back from the ## Status
|
|
55
|
+
# table (D8r's round-trip guarantee).
|
|
56
|
+
def status_rows(path)
|
|
57
|
+
parsed = parse(path)
|
|
58
|
+
rows_from_table(parsed[:status])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Replace the whole ## Status section with rows, creating the section if
|
|
62
|
+
# absent. Refuses a cyclic graph (D8r); goes through AtomicWrite (D19r).
|
|
63
|
+
def write_status(path, rows, renamer: File.method(:rename))
|
|
64
|
+
guard = refuse_if_cyclic(path)
|
|
65
|
+
return guard if guard
|
|
66
|
+
|
|
67
|
+
content = File.read(path)
|
|
68
|
+
new_content = replace_or_append_section(content, "## Status", render_status_table(rows))
|
|
69
|
+
AtomicWrite.write(path, new_content, renamer: renamer)
|
|
70
|
+
{ ok: true, errors: [] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Append one answered decision as a list item to ## Decisions (C26),
|
|
74
|
+
# leaving every other section byte-identical. Refuses a cyclic graph.
|
|
75
|
+
def append_decision(path, text, renamer: File.method(:rename))
|
|
76
|
+
guard = refuse_if_cyclic(path)
|
|
77
|
+
return guard if guard
|
|
78
|
+
|
|
79
|
+
content = File.read(path)
|
|
80
|
+
bounds = section_bounds(content, "## Decisions")
|
|
81
|
+
return { ok: false, errors: ["missing ## Decisions section"] } unless bounds
|
|
82
|
+
|
|
83
|
+
start_idx, end_idx = bounds
|
|
84
|
+
heading_line_end = line_end(content, start_idx)
|
|
85
|
+
body = content[heading_line_end...end_idx]
|
|
86
|
+
trimmed = body.rstrip
|
|
87
|
+
trailing = body[trimmed.length..]
|
|
88
|
+
new_body = trimmed.empty? ? "- #{text}#{trailing}" : "#{trimmed}\n- #{text}#{trailing}"
|
|
89
|
+
new_content = content[0...heading_line_end] + new_body + content[end_idx..]
|
|
90
|
+
AtomicWrite.write(path, new_content, renamer: renamer)
|
|
91
|
+
{ ok: true, errors: [] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def refuse_if_cyclic(path)
|
|
95
|
+
parsed = parse(path)
|
|
96
|
+
return { ok: false, errors: parsed[:errors] } if parsed[:graph].nil?
|
|
97
|
+
|
|
98
|
+
cyc = GraphEdges.cycle(parsed[:graph][:edges])
|
|
99
|
+
return { ok: false, errors: ["cyclic graph, refusing to write: #{cyc.join(' > ')}"] } if cyc
|
|
100
|
+
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- verify: none directive --------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def extract_verify_directive(graph_section)
|
|
107
|
+
return [nil, [], graph_section] if graph_section.nil?
|
|
108
|
+
|
|
109
|
+
reason = nil
|
|
110
|
+
present = false
|
|
111
|
+
errors = []
|
|
112
|
+
remaining_lines = []
|
|
113
|
+
|
|
114
|
+
graph_section.each_line do |line|
|
|
115
|
+
stripped = line.strip
|
|
116
|
+
m = stripped.match(DIRECTIVE_RE)
|
|
117
|
+
if m
|
|
118
|
+
present = true
|
|
119
|
+
captured = m[1].to_s.strip
|
|
120
|
+
if captured.empty?
|
|
121
|
+
errors << "verify: none requires reason=<text>: #{stripped.inspect}"
|
|
122
|
+
else
|
|
123
|
+
reason = captured
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
remaining_lines << line
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
directive = present ? { reason: reason } : nil
|
|
131
|
+
[directive, errors, remaining_lines.join]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# D18: drop every fenced line from `text`, so a fenced example edge inside a
|
|
135
|
+
# hand-written graph.md or roadmap ## Graph section is never handed to
|
|
136
|
+
# GraphEdges as real edge text. Non-fenced lines pass through unchanged.
|
|
137
|
+
def strip_fenced_blocks(text)
|
|
138
|
+
lines = []
|
|
139
|
+
each_fence_line(text) { |line, fenced| lines << line unless fenced }
|
|
140
|
+
lines.join
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- fence-aware section location ---------------------------------------------
|
|
144
|
+
|
|
145
|
+
def each_fence_line(text)
|
|
146
|
+
return enum_for(:each_fence_line, text) unless block_given?
|
|
147
|
+
|
|
148
|
+
marker = nil
|
|
149
|
+
text.to_s.each_line do |line|
|
|
150
|
+
if marker
|
|
151
|
+
yield line, true
|
|
152
|
+
m = line.match(FENCE_LINE_RE)
|
|
153
|
+
next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
|
|
154
|
+
next unless line.sub(FENCE_LINE_RE, "").strip.empty?
|
|
155
|
+
|
|
156
|
+
marker = nil
|
|
157
|
+
else
|
|
158
|
+
m = line.match(FENCE_LINE_RE)
|
|
159
|
+
if m
|
|
160
|
+
marker = [m[1][0], m[1].length]
|
|
161
|
+
yield line, true
|
|
162
|
+
else
|
|
163
|
+
yield line, false
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# [start_of_heading_line, start_of_next_top_level_heading_or_EOF] byte
|
|
170
|
+
# offsets for the FIRST line, outside any fence, whose stripped text
|
|
171
|
+
# exactly equals heading_text. nil when no such line exists.
|
|
172
|
+
def section_bounds(content, heading_text)
|
|
173
|
+
offset = 0
|
|
174
|
+
heading_start = nil
|
|
175
|
+
|
|
176
|
+
each_fence_line(content) do |line, fenced|
|
|
177
|
+
if !fenced && heading_start.nil? && line.strip == heading_text
|
|
178
|
+
heading_start = offset
|
|
179
|
+
elsif !fenced && heading_start && offset > heading_start && line.match?(/\A##[^#]/)
|
|
180
|
+
return [heading_start, offset]
|
|
181
|
+
end
|
|
182
|
+
offset += line.length
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
heading_start ? [heading_start, offset] : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def section_body(content, heading_text)
|
|
189
|
+
bounds = section_bounds(content, heading_text)
|
|
190
|
+
return nil unless bounds
|
|
191
|
+
|
|
192
|
+
start_idx, end_idx = bounds
|
|
193
|
+
content[(line_end(content, start_idx))...end_idx].to_s
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def line_end(content, start_idx)
|
|
197
|
+
idx = content.index("\n", start_idx)
|
|
198
|
+
idx ? idx + 1 : content.length
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def replace_or_append_section(content, heading_text, body_text)
|
|
202
|
+
rendered_body = body_text.to_s
|
|
203
|
+
rendered_body += "\n" unless rendered_body.end_with?("\n")
|
|
204
|
+
section = "#{heading_text}\n#{rendered_body}"
|
|
205
|
+
|
|
206
|
+
bounds = section_bounds(content, heading_text)
|
|
207
|
+
if bounds
|
|
208
|
+
start_idx, end_idx = bounds
|
|
209
|
+
tail = content[end_idx..].to_s
|
|
210
|
+
section += "\n" unless tail.empty?
|
|
211
|
+
content[0...start_idx] + section + tail
|
|
212
|
+
else
|
|
213
|
+
head = content.dup
|
|
214
|
+
head += "\n" unless head.end_with?("\n")
|
|
215
|
+
head += "\n" unless head.end_with?("\n\n")
|
|
216
|
+
head + section
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# --- table rendering and reading -----------------------------------------------
|
|
221
|
+
|
|
222
|
+
def render_status_table(rows)
|
|
223
|
+
lines = ["| Node | State | Detail |", "| --- | --- | --- |"]
|
|
224
|
+
rows.each { |r| lines << "| #{r[:node]} | #{r[:state]} | #{r[:detail]} |" }
|
|
225
|
+
"#{lines.join("\n")}\n"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def rows_from_table(text)
|
|
229
|
+
return [] if text.nil?
|
|
230
|
+
|
|
231
|
+
lines = []
|
|
232
|
+
each_fence_line(text) do |line, fenced|
|
|
233
|
+
next if fenced
|
|
234
|
+
|
|
235
|
+
stripped = line.strip
|
|
236
|
+
lines << stripped if stripped.start_with?("|")
|
|
237
|
+
end
|
|
238
|
+
sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
|
|
239
|
+
return [] unless sep_idx
|
|
240
|
+
|
|
241
|
+
lines[(sep_idx + 1)..].map do |l|
|
|
242
|
+
cells = l.split("|", -1).map(&:strip)[1..-2].to_a
|
|
243
|
+
{ node: cells[0], state: cells[1], detail: cells[2] }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_edges"
|
|
5
|
+
require_relative "ready_set"
|
|
6
|
+
|
|
7
|
+
# GraphTree (intent 337, n2, folds 327a): draws any edge map ({id => [needs...]})
|
|
8
|
+
# as an indented tree with box-drawing branches. A node may have any number
|
|
9
|
+
# of children; a node several branches need (fan-out) is drawn once, at the
|
|
10
|
+
# point where those branches join. A node that itself needs several things
|
|
11
|
+
# (fan-in) can only occupy one position in the tree: it hangs under its
|
|
12
|
+
# highest-batch need, ties broken by smallest id, with its other needs shown
|
|
13
|
+
# as converging references at the join (row 2.14, folded at the 2026-09-10
|
|
14
|
+
# plan review). Pure, plain text, no roadmap knowledge, so the same
|
|
15
|
+
# renderer serves the roadmap file, the roadmap screens, and later the node
|
|
16
|
+
# scope. Batch numbers (for placement) and the topological sort come from
|
|
17
|
+
# ReadySet - the ONE sort (327 D1) - never a second local one here.
|
|
18
|
+
module GraphTree
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def render(edges:, labels:, marks:, width:)
|
|
22
|
+
edges = edges || {}
|
|
23
|
+
nodes = ReadySet.all_nodes(edges)
|
|
24
|
+
|
|
25
|
+
cyc = GraphEdges.cycle(edges)
|
|
26
|
+
if cyc
|
|
27
|
+
return { ok: false, text: nil, error: "cyclic graph, cannot render a tree: #{cyc.join(' > ')}", cycle: cyc }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if nodes.empty?
|
|
31
|
+
return { ok: true, text: "(no dependencies)\n", error: nil, cycle: nil }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
batch_result = ReadySet.batches(edges)
|
|
35
|
+
batch_of = {}
|
|
36
|
+
batch_result[:batches].each_with_index { |layer, i| layer.each { |id| batch_of[id] = i + 1 } }
|
|
37
|
+
|
|
38
|
+
primary_parent = {}
|
|
39
|
+
converging = {}
|
|
40
|
+
nodes.each do |id|
|
|
41
|
+
needs = (edges[id] || []).uniq
|
|
42
|
+
next if needs.empty?
|
|
43
|
+
|
|
44
|
+
primary = needs.min_by { |n| [-(batch_of[n] || 0), n] }
|
|
45
|
+
primary_parent[id] = primary
|
|
46
|
+
others = needs - [primary]
|
|
47
|
+
converging[id] = others.sort unless others.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
children_of = Hash.new { |h, k| h[k] = [] }
|
|
51
|
+
primary_parent.each { |child, parent| children_of[parent] << child }
|
|
52
|
+
children_of.each_value { |list| list.sort! { |a, b| [batch_of[a] || 0, a] <=> [batch_of[b] || 0, b] } }
|
|
53
|
+
|
|
54
|
+
roots = nodes.select { |id| (edges[id] || []).empty? }.sort
|
|
55
|
+
|
|
56
|
+
labels ||= {}
|
|
57
|
+
marks ||= {}
|
|
58
|
+
critical = (marks[:critical_path] || []).to_a
|
|
59
|
+
ready = (marks[:ready] || []).to_a
|
|
60
|
+
|
|
61
|
+
lines = []
|
|
62
|
+
roots.each { |id| append_node(id, "", "", lines, children_of, labels, critical, ready, converging, width) }
|
|
63
|
+
|
|
64
|
+
{ ok: true, text: "#{lines.join("\n")}\n", error: nil, cycle: nil }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# `ancestor_prefix` is the continuation string every ancestor above this
|
|
68
|
+
# node contributes ("│ " when that ancestor still has a later sibling
|
|
69
|
+
# to draw, " " when it was the last child); `connector` is this node's
|
|
70
|
+
# own branch glyph off its parent ("├── " / "└── "), empty for a root.
|
|
71
|
+
def append_node(id, ancestor_prefix, connector, lines, children_of, labels, critical, ready, converging, width)
|
|
72
|
+
lines << render_line(ancestor_prefix + connector, id, labels, critical, ready, converging, width)
|
|
73
|
+
|
|
74
|
+
continuation = connector == "└── " ? " " : (connector.empty? ? "" : "│ ")
|
|
75
|
+
child_ancestor_prefix = ancestor_prefix + continuation
|
|
76
|
+
|
|
77
|
+
children = children_of[id] || []
|
|
78
|
+
children.each_with_index do |child_id, i|
|
|
79
|
+
last = i == children.length - 1
|
|
80
|
+
child_connector = last ? "└── " : "├── "
|
|
81
|
+
append_node(child_id, child_ancestor_prefix, child_connector, lines, children_of, labels, critical, ready, converging, width)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_line(connector, id, labels, critical, ready, converging, width)
|
|
86
|
+
label = labels.fetch(id, id).to_s
|
|
87
|
+
text = critical.include?(id) ? "* #{label}" : label
|
|
88
|
+
text += " (also needs #{converging[id].join(', ')})" if converging[id]
|
|
89
|
+
text += " (ready)" if ready.include?(id)
|
|
90
|
+
|
|
91
|
+
available = width - connector.length
|
|
92
|
+
if available.positive? && text.length > available
|
|
93
|
+
text = available > 3 ? "#{text[0, available - 3]}..." : text[0, available]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
"#{connector}#{text}"
|
|
97
|
+
end
|
|
98
|
+
end
|