@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.
Files changed (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. 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 five-event map of
31
- # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301).
32
- CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd].freeze
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
- content = File.read(path).gsub(%r{//[^\n]*}, "").gsub(/,(\s*[}\]])/, '\1')
94
- JSON.parse(content)
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