@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
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # report-screen - the pre-delivery plan (intent 331b), the four delivery
6
+ # reports: mid-delivery state, post-delivery delivered, and delay (intent
7
+ # 317), plus session, the whole of one session's delivered work followed by
8
+ # the roster (intent 330). Each fills from the record via
9
+ # scripts/lib/report_screen.rb; no number here is written by eye.
10
+ #
11
+ # Usage:
12
+ # report-screen plan <intent_dir> [--ansi]
13
+ # report-screen state <intent_dir> [--changed "<text>"] [--ansi]
14
+ # report-screen state --all <store_root> [--changed "<text>"] [--ansi]
15
+ # report-screen delivered <intent_dir> [--ansi] [--repo <dir>]
16
+ # report-screen delay <intent_dir> [--ansi]
17
+ # report-screen session <tier_root> [--session <id>] [--since <iso>]
18
+ # [--ledger-root <dir>] [--ansi]
19
+ # report-screen roadmap <roadmap.md> plan|state|delivered [--ansi] [--store-root <dir>]
20
+ # report-screen archive <store_root> [--ansi]
21
+ #
22
+ # --ansi delegates to ScreenPaint (intent 317a, D1), the parser/re-layouter
23
+ # in the shared TUI core. Selection is by capability, never by harness:
24
+ # NO_COLOR forces plain, a non-TTY stdout stays plain unless
25
+ # PLASTIC_FORCE_COLOR=1 (the test seam), and an unparseable screen falls
26
+ # open to the plain text untouched.
27
+ #
28
+ # Exit codes:
29
+ # 0 - the screen is on stdout
30
+ # 2 - usage error, or the path/store is not what the verb expects (one line
31
+ # on stderr, stdout empty)
32
+
33
+ require "yaml"
34
+ require "shellwords"
35
+ require_relative "lib/report_screen"
36
+ require_relative "lib/intent_screen"
37
+ require_relative "lib/screen_paint"
38
+ require_relative "lib/session_ledger"
39
+
40
+ # Intent 331a (D6/R8): every caller-added screen kind file registers itself
41
+ # on load (ScreenPaint.register), so this glob is the ONLY wiring a new kind
42
+ # needs - no edit here, no edit to screen_paint.rb. Sorted for a
43
+ # deterministic load order; tolerates the directory being absent or empty
44
+ # (Dir.glob answers [] either way), which is this repo's own state today.
45
+ Dir.glob(File.join(__dir__, "lib", "screens", "*.rb")).sort.each { |f| require_relative f }
46
+
47
+ def usage_abort(message)
48
+ warn "report-screen: #{message}"
49
+ exit 2
50
+ end
51
+
52
+ # The one place allowed to shell out (D2/D20): resolves the tag that CONTAINS
53
+ # the intent's merge commit, in the project's own repository, so the version
54
+ # on the delivered screen is the release that merge shipped in. The
55
+ # repository comes from --repo, else from projects.yml beside the store (the
56
+ # installed layout, <home>/projects/<slug>/store/<id>), else the repository
57
+ # this script lives in (the in-repo layout, `include_self: true`) - unless
58
+ # `include_self: false` (the branch reader, D23), in which case a global
59
+ # intent's repo never falls back to the script's own repository, since that
60
+ # is not the intent's repository at all. No merge sha, no repository, or no
61
+ # containing tag all answer nil, and the screen says "not recorded" rather
62
+ # than guessing from HEAD (D14). The pure module never reads git.
63
+ def resolve_repo(intent_dir, explicit_repo, script_dir, include_self: true)
64
+ candidates = []
65
+ candidates << File.expand_path(explicit_repo) if explicit_repo
66
+ if (m = intent_dir.match(%r{\A(.*)/projects/([^/]+)/store/[^/]+\z}))
67
+ home, slug = m[1], m[2]
68
+ projects_yml = File.join(home, "projects.yml")
69
+ if File.exist?(projects_yml)
70
+ data = begin
71
+ YAML.safe_load(File.read(projects_yml))
72
+ rescue StandardError
73
+ nil
74
+ end
75
+ entry = data.is_a?(Hash) && data["projects"].is_a?(Hash) ? data["projects"][slug] : nil
76
+ path = entry.is_a?(Hash) ? entry["path"].to_s : ""
77
+ candidates << File.expand_path(path) unless path.empty?
78
+ end
79
+ end
80
+ candidates << File.expand_path("..", script_dir) if include_self
81
+ candidates.find { |c| File.exist?(File.join(c, ".git")) }
82
+ end
83
+
84
+ def git_tag_reader(explicit_repo:, script_dir:)
85
+ lambda do |intent_dir|
86
+ sha = ReportScreen.merge_sha(intent_dir)
87
+ return nil unless sha
88
+ repo = resolve_repo(intent_dir, explicit_repo, script_dir)
89
+ return nil unless repo
90
+ tags = `git -C #{repo.shellescape} tag --contains #{sha.shellescape} 2>/dev/null`
91
+ versions = tags.lines.map(&:strip).reject(&:empty?).filter_map do |tag|
92
+ v = tag.sub(/\Av/, "")
93
+ Gem::Version.correct?(v) ? [Gem::Version.new(v), tag] : nil
94
+ end
95
+ versions.min_by(&:first)&.last
96
+ end
97
+ end
98
+
99
+ # D9/D23: the injected branch reader. flow_base (a pure YAML read, never
100
+ # git) wins when the intent's project names a flow base; otherwise the
101
+ # repository's remote HEAD, else whichever of main/master exists. Resolves
102
+ # the repo with `include_self: false` (D23): a global-store intent must
103
+ # never fall back to the script's OWN repository (~/.plastic itself, on
104
+ # "main" in the installed layout) and print a confident, wrong branch.
105
+ def git_branch_reader(explicit_repo:, script_dir:)
106
+ lambda do |intent_dir|
107
+ flow = ReportScreen.flow_base(intent_dir)
108
+ next flow if flow && !flow.to_s.empty?
109
+
110
+ repo = resolve_repo(intent_dir, explicit_repo, script_dir, include_self: false)
111
+ next nil unless repo
112
+
113
+ branch = `git -C #{repo.shellescape} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`.strip
114
+ branch = branch.sub(%r{\Aorigin/}, "")
115
+ next branch unless branch.empty?
116
+
117
+ %w[main master].find do |b|
118
+ system("git", "-C", repo, "rev-parse", "--verify", "--quiet", b, out: File::NULL, err: File::NULL)
119
+ end
120
+ end
121
+ end
122
+
123
+ args = ARGV.dup
124
+ verb = args.shift
125
+ changed = nil
126
+ ansi = false
127
+ template_path = nil
128
+ repo_flag = nil
129
+ session_flag = nil
130
+ since_flag = nil
131
+ ledger_root_flag = nil
132
+ store_root_flag = nil
133
+ positional = []
134
+
135
+ while (arg = args.shift)
136
+ case arg
137
+ when "--changed"
138
+ usage_abort("--changed needs a value") if args.empty?
139
+ changed = args.shift
140
+ when "--ansi"
141
+ ansi = true
142
+ when "--all"
143
+ positional << "--all"
144
+ when "--repo"
145
+ repo_flag = args.shift or usage_abort("--repo needs a path")
146
+ when "--template"
147
+ template_path = args.shift or usage_abort("--template needs a path")
148
+ when "--session"
149
+ session_flag = args.shift or usage_abort("--session needs a value")
150
+ when "--since"
151
+ since_flag = args.shift or usage_abort("--since needs a value")
152
+ when "--ledger-root"
153
+ ledger_root_flag = args.shift or usage_abort("--ledger-root needs a path")
154
+ when "--store-root"
155
+ store_root_flag = args.shift or usage_abort("--store-root needs a path")
156
+ else
157
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--") && arg != "--all"
158
+ positional << arg
159
+ end
160
+ end
161
+
162
+ usage_abort("usage: report-screen plan|state|delivered|delay|session <intent_dir> [--changed \"<text>\"] [--ansi]") unless verb
163
+
164
+ all_mode = positional.delete("--all") ? true : false
165
+ target = positional.first
166
+
167
+ ansi_enabled = ansi && ENV["NO_COLOR"].to_s.empty? &&
168
+ ($stdout.tty? || ENV["PLASTIC_FORCE_COLOR"] == "1")
169
+
170
+ def paint(text, ansi_enabled, _renderer_path = nil)
171
+ return text unless ansi_enabled
172
+ ScreenPaint.paint(text, color: true) || text
173
+ end
174
+
175
+ case verb
176
+ when "plan"
177
+ usage_abort("usage: report-screen plan <intent_dir> [--ansi]") unless target
178
+ intent_dir = File.expand_path(target)
179
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
180
+ store_root = File.expand_path("../..", intent_dir)
181
+ template_path ||= File.expand_path("../templates/report-plan.md", __dir__)
182
+ usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
183
+ out = ReportScreen.render_plan(intent_dir: intent_dir, store_root: store_root,
184
+ template: File.read(template_path))
185
+ $stdout.write paint(out, ansi_enabled)
186
+ when "state"
187
+ if all_mode
188
+ usage_abort("usage: report-screen state --all <store_root>") unless target
189
+ store_root = File.expand_path(target)
190
+ usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
191
+ out = ReportScreen.render_roster(store_root, changed: changed)
192
+ $stdout.write paint(out, ansi_enabled)
193
+ else
194
+ usage_abort("usage: report-screen state <intent_dir> [--changed \"<text>\"]") unless target
195
+ intent_dir = File.expand_path(target)
196
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
197
+ store_root = File.expand_path("../..", intent_dir)
198
+ template_path ||= File.expand_path("../templates/report-state.md", __dir__)
199
+ usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
200
+ out = ReportScreen.render_state(intent_dir: intent_dir, store_root: store_root, changed: changed,
201
+ template: File.read(template_path))
202
+ $stdout.write paint(out, ansi_enabled)
203
+ end
204
+ when "delivered"
205
+ usage_abort("usage: report-screen delivered <intent_dir>") unless target
206
+ intent_dir = File.expand_path(target)
207
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
208
+ out = ReportScreen.render_delivered(intent_dir: intent_dir,
209
+ tag_reader: git_tag_reader(explicit_repo: repo_flag, script_dir: __dir__),
210
+ branch_reader: git_branch_reader(explicit_repo: repo_flag, script_dir: __dir__))
211
+ $stdout.write paint(out, ansi_enabled)
212
+ when "delay"
213
+ usage_abort("usage: report-screen delay <intent_dir>") unless target
214
+ intent_dir = File.expand_path(target)
215
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
216
+ out = ReportScreen.render_delay(intent_dir: intent_dir)
217
+ $stdout.write paint(out, ansi_enabled)
218
+ when "session"
219
+ usage_abort("usage: report-screen session <tier_root> [--session <id>] [--since <iso>] [--ledger-root <dir>] [--ansi]") unless target
220
+ store_root = File.expand_path(target)
221
+ usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
222
+
223
+ now = Time.now
224
+ session_id = session_flag || ENV[SessionLedger::SESSION_ID_ENV_KEY]
225
+ ledger_root = ledger_root_flag ? File.expand_path(ledger_root_flag) : ReportScreen.default_ledger_root(store_root)
226
+
227
+ if since_flag
228
+ begin
229
+ Time.parse(since_flag)
230
+ rescue ArgumentError
231
+ usage_abort("--since needs an ISO timestamp, got #{since_flag.inspect}")
232
+ end
233
+ end
234
+
235
+ dirs, skipped = ReportScreen.session_delivered_dirs(ledger_root: ledger_root, tier_root: store_root,
236
+ session: session_id, since: since_flag, now: now)
237
+ # D17: the widened window is announced whether the id was missing or simply
238
+ # matched nothing. A resumed background job carries a session id that is not
239
+ # the one on the ledger lines, and that case must not answer silently. An
240
+ # explicit --since is the caller naming the window, so it needs no note.
241
+ note =
242
+ if since_flag
243
+ nil
244
+ elsif session_id.nil? || session_id.to_s.strip.empty?
245
+ ReportScreen.window_note(ReportScreen.fallback_day(ledger_root, SessionLedger.day_id(now)),
246
+ "no session id given")
247
+ elsif !ReportScreen.session_tagged?(ledger_root: ledger_root, session: session_id, now: now)
248
+ ReportScreen.window_note(ReportScreen.fallback_day(ledger_root, SessionLedger.day_id(now)),
249
+ "this session has no line in the day ledger")
250
+ end
251
+
252
+ out = ReportScreen.render_session(
253
+ dirs: dirs, skipped: skipped, store_root: store_root,
254
+ tag_reader: git_tag_reader(explicit_repo: nil, script_dir: __dir__),
255
+ branch_reader: git_branch_reader(explicit_repo: nil, script_dir: __dir__),
256
+ note: note, now: now, painter: ->(text) { paint(text, ansi_enabled) }
257
+ )
258
+ $stdout.write out
259
+ when "roadmap"
260
+ usage_abort("usage: report-screen roadmap <roadmap.md> plan|state|delivered [--ansi] [--store-root <dir>]") if positional.empty?
261
+ roadmap_path = File.expand_path(positional[0])
262
+ usage_abort("#{roadmap_path} does not exist") unless File.exist?(roadmap_path)
263
+ sub_verb = positional[1]
264
+ usage_abort("usage: report-screen roadmap <roadmap.md> plan|state|delivered") unless sub_verb
265
+ unless %w[plan state delivered].include?(sub_verb)
266
+ usage_abort("unknown sub-verb #{sub_verb.inspect} (use plan|state|delivered)")
267
+ end
268
+ roadmap_store_root = store_root_flag ? File.expand_path(store_root_flag) : nil
269
+ out = ReportScreen.render_roadmap(path: roadmap_path, verb: sub_verb, store_root: roadmap_store_root)
270
+ $stdout.write paint(out, ansi_enabled)
271
+ when "archive"
272
+ usage_abort("usage: report-screen archive <store_root>") unless target
273
+ store_root = File.expand_path(target)
274
+ usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
275
+ out = ReportScreen.render_archive(store_root)
276
+ $stdout.write paint(out, ansi_enabled)
277
+ else
278
+ usage_abort("unknown verb #{verb.inspect} (use plan|state|delivered|delay|session|roadmap|archive)")
279
+ end
280
+
281
+ exit 0
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # roadmap-graph - the CLI over the roadmap graph model (intent 337, G4):
6
+ # `check`, `render`, `migrate`, every verb honouring --dry-run.
7
+ #
8
+ # Usage:
9
+ # roadmap-graph check <roadmap.md> [--dry-run]
10
+ # roadmap-graph render <roadmap.md> [--dry-run]
11
+ # roadmap-graph migrate <roadmap.md> [--dry-run]
12
+ #
13
+ # Exit codes:
14
+ # 0 - printed / rendered / migrated cleanly (or a migrate skip: the
15
+ # roadmap already carries a graph)
16
+ # 1 - a real finding: a cyclic graph, a dangling id, or a render/migrate
17
+ # refusal (no ## Graph, no grouping heading)
18
+ # 2 - usage: no argument, an unknown verb, or a missing/non-file path
19
+
20
+ require_relative "lib/roadmap_graph"
21
+ require_relative "lib/roadmap_render"
22
+ require_relative "lib/roadmap_migration"
23
+
24
+ module RoadmapGraphCli
25
+ module_function
26
+
27
+ VERBS = %w[check render migrate].freeze
28
+
29
+ def usage
30
+ warn "Usage: roadmap-graph <#{VERBS.join('|')}> <roadmap.md> [--dry-run]"
31
+ end
32
+
33
+ def main(argv)
34
+ args = argv.dup
35
+ dry_run = !!args.delete("--dry-run")
36
+
37
+ verb = args.shift
38
+ unless verb && VERBS.include?(verb)
39
+ warn "roadmap-graph: unknown verb #{verb.inspect} (use #{VERBS.join(', ')})" if verb
40
+ usage
41
+ return 2
42
+ end
43
+
44
+ path = args.shift
45
+ unless path && File.file?(path)
46
+ warn "roadmap-graph: #{path.inspect} is not a file"
47
+ return 2
48
+ end
49
+
50
+ case verb
51
+ when "check" then check(path)
52
+ when "render" then render(path, dry_run)
53
+ when "migrate" then migrate(path, dry_run)
54
+ end
55
+ end
56
+
57
+ def check(path)
58
+ result = RoadmapGraph.analyze(path)
59
+ unless result[:has_graph]
60
+ warn "roadmap-graph check: #{result[:reason]}"
61
+ return 1
62
+ end
63
+ if result[:cycle]
64
+ warn "roadmap-graph check: cyclic graph: #{result[:cycle].join(' > ')}"
65
+ return 1
66
+ end
67
+ if result[:reason]
68
+ warn "roadmap-graph check: #{result[:reason]}"
69
+ return 1
70
+ end
71
+
72
+ puts "Batches: #{result[:batches].map { |b| b.join(', ') }.join(' | ')}"
73
+ puts "Ready: #{result[:ready].join(', ')}"
74
+ puts "Dead ends: #{result[:dead_ends].join(', ')}" unless result[:dead_ends].empty?
75
+
76
+ unless result[:dangling].empty?
77
+ warn "roadmap-graph check: graph names #{result[:dangling].map(&:inspect).join(', ')}, no batch entry"
78
+ return 1
79
+ end
80
+
81
+ 0
82
+ end
83
+
84
+ def render(path, dry_run)
85
+ result = RoadmapRender.write(path, dry_run: dry_run)
86
+ unless result[:ok]
87
+ warn "roadmap-graph render: #{result[:error]}"
88
+ return 1
89
+ end
90
+
91
+ if dry_run
92
+ puts result[:content]
93
+ else
94
+ puts "roadmap-graph render: wrote #{path}"
95
+ end
96
+ 0
97
+ end
98
+
99
+ def migrate(path, dry_run)
100
+ result = RoadmapMigration.write(path, dry_run: dry_run)
101
+ unless result[:ok]
102
+ if result[:skipped]
103
+ puts "roadmap-graph migrate: #{result[:reason]}"
104
+ return 0
105
+ end
106
+ warn "roadmap-graph migrate: #{result[:reason]}"
107
+ return 1
108
+ end
109
+
110
+ if dry_run
111
+ puts result[:content]
112
+ else
113
+ puts "roadmap-graph migrate: wrote #{path}"
114
+ end
115
+ 0
116
+ end
117
+ end
118
+
119
+ exit(RoadmapGraphCli.main(ARGV)) if $PROGRAM_NAME == __FILE__
@@ -18,6 +18,7 @@
18
18
  # rebuilds an empty ledger.
19
19
 
20
20
  require_relative "lib/roadmap_savepoint"
21
+ require_relative "lib/guarded_append"
21
22
 
22
23
  def opt(args, name)
23
24
  (i = args.index(name)) && args[i + 1]
@@ -47,6 +48,12 @@ when "append"
47
48
  rescue ArgumentError => e
48
49
  warn "append: #{e.message}"
49
50
  exit 2
51
+ rescue GuardedAppend::Unavailable => e
52
+ # Intent 335 (spec D14): the write guard could not be taken; nothing was written. Exit 4
53
+ # rather than let a backtrace reach the six skill recipes that drive this CLI - it rescues
54
+ # ArgumentError alone today and would otherwise print one straight to the terminal.
55
+ warn "append: #{e.message}"
56
+ exit 4
50
57
  end
51
58
 
52
59
  when "rebuild"