tina4ruby 3.11.15 → 3.11.17

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +1291 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -124
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -116
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2087 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +871 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/plan.rb +471 -0
  63. data/lib/tina4/project_index.rb +366 -0
  64. data/lib/tina4/public/css/tina4.css +2463 -2463
  65. data/lib/tina4/public/css/tina4.min.css +1 -1
  66. data/lib/tina4/public/images/logo.svg +5 -5
  67. data/lib/tina4/public/js/frond.min.js +2 -2
  68. data/lib/tina4/public/js/tina4-dev-admin.js +1264 -565
  69. data/lib/tina4/public/js/tina4-dev-admin.min.js +1264 -480
  70. data/lib/tina4/public/js/tina4.min.js +92 -92
  71. data/lib/tina4/public/js/tina4js.min.js +48 -48
  72. data/lib/tina4/public/swagger/index.html +90 -90
  73. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  74. data/lib/tina4/query_builder.rb +380 -380
  75. data/lib/tina4/queue.rb +366 -366
  76. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  77. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  78. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  79. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  80. data/lib/tina4/rack_app.rb +817 -817
  81. data/lib/tina4/rate_limiter.rb +130 -130
  82. data/lib/tina4/request.rb +268 -268
  83. data/lib/tina4/response.rb +346 -346
  84. data/lib/tina4/response_cache.rb +551 -551
  85. data/lib/tina4/router.rb +406 -406
  86. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  87. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  88. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  89. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  90. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  91. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  92. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  93. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  94. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  95. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  96. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  97. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  98. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  99. data/lib/tina4/scss/tina4css/base.scss +1 -1
  100. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  101. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  102. data/lib/tina4/scss_compiler.rb +178 -178
  103. data/lib/tina4/seeder.rb +567 -567
  104. data/lib/tina4/service_runner.rb +303 -303
  105. data/lib/tina4/session.rb +297 -297
  106. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  107. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  108. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  109. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  110. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  111. data/lib/tina4/shutdown.rb +84 -84
  112. data/lib/tina4/sql_translation.rb +158 -158
  113. data/lib/tina4/swagger.rb +124 -124
  114. data/lib/tina4/template.rb +894 -894
  115. data/lib/tina4/templates/base.twig +26 -26
  116. data/lib/tina4/templates/errors/302.twig +14 -14
  117. data/lib/tina4/templates/errors/401.twig +9 -9
  118. data/lib/tina4/templates/errors/403.twig +29 -29
  119. data/lib/tina4/templates/errors/404.twig +29 -29
  120. data/lib/tina4/templates/errors/500.twig +38 -38
  121. data/lib/tina4/templates/errors/502.twig +9 -9
  122. data/lib/tina4/templates/errors/503.twig +12 -12
  123. data/lib/tina4/templates/errors/base.twig +37 -37
  124. data/lib/tina4/test_client.rb +159 -159
  125. data/lib/tina4/testing.rb +340 -340
  126. data/lib/tina4/validator.rb +174 -174
  127. data/lib/tina4/version.rb +1 -1
  128. data/lib/tina4/webserver.rb +312 -312
  129. data/lib/tina4/websocket.rb +343 -343
  130. data/lib/tina4/websocket_backplane.rb +190 -190
  131. data/lib/tina4/wsdl.rb +564 -564
  132. data/lib/tina4.rb +460 -458
  133. data/lib/tina4ruby.rb +4 -4
  134. metadata +5 -3
data/lib/tina4/plan.rb ADDED
@@ -0,0 +1,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tina4::Plan — project plan storage + manipulation, ported from
4
+ # tina4_python/dev_admin/plan.py. Plan files are canonical markdown
5
+ # under plan/ at the project root; exactly one plan is "current"
6
+ # (filename stored in plan/.current).
7
+ #
8
+ # File format is byte-for-byte identical to the Python reference —
9
+ # title (# Title), optional "Goal: ...", "## Steps" with "- [ ]" /
10
+ # "- [x]" checkboxes, and optional "## Notes" trailer.
11
+
12
+ require "json"
13
+ require "fileutils"
14
+ require "net/http"
15
+ require "uri"
16
+
17
+ module Tina4
18
+ module Plan
19
+ PLAN_DIR = "plan"
20
+ CURRENT_FILE = ".current"
21
+ ARCHIVE_SUBDIR = "done"
22
+
23
+ STEP_RE = /\A\s*[-*]\s*\[(?<box>[ xX])\]\s*(?<text>.+?)\s*\z/.freeze
24
+
25
+ class << self
26
+ # ── Paths ──────────────────────────────────────────────────
27
+
28
+ def project_root
29
+ File.expand_path(Dir.pwd)
30
+ end
31
+
32
+ def plan_dir
33
+ p = File.join(project_root, PLAN_DIR)
34
+ FileUtils.mkdir_p(p)
35
+ p
36
+ end
37
+
38
+ def current_pointer
39
+ File.join(plan_dir, CURRENT_FILE)
40
+ end
41
+
42
+ def archive_dir
43
+ p = File.join(plan_dir, ARCHIVE_SUBDIR)
44
+ FileUtils.mkdir_p(p)
45
+ p
46
+ end
47
+
48
+ def slugify(title)
49
+ slug = title.to_s.strip.downcase.gsub(/[^a-z0-9_\-]+/, "-").gsub(/\A-+|-+\z/, "")
50
+ slug = slug[0, 80]
51
+ slug.empty? ? "plan-#{Time.now.to_i}" : slug
52
+ end
53
+
54
+ # ── Parse / render ─────────────────────────────────────────
55
+
56
+ def parse(text)
57
+ lines = text.to_s.split("\n", -1)
58
+ title = ""
59
+ goal = ""
60
+ steps = []
61
+ notes_lines = []
62
+ section = nil # :steps | :notes | :other | nil
63
+
64
+ lines.each do |raw|
65
+ line = raw.sub(/[[:space:]]+\z/, "")
66
+ if title.empty? && line.start_with?("# ")
67
+ title = line[2..].to_s.strip
68
+ next
69
+ end
70
+ low = line.strip.downcase
71
+ if low.start_with?("goal:") && goal.empty?
72
+ goal = line.split(":", 2)[1].to_s.strip
73
+ next
74
+ end
75
+ if low == "## steps"
76
+ section = :steps
77
+ next
78
+ end
79
+ if low == "## notes"
80
+ section = :notes
81
+ next
82
+ end
83
+ if line.start_with?("## ")
84
+ section = :other
85
+ next
86
+ end
87
+ if section == :steps
88
+ m = STEP_RE.match(line)
89
+ steps << { "text" => m[:text].strip, "done" => m[:box].downcase == "x" } if m
90
+ elsif section == :notes && !line.strip.empty?
91
+ notes_lines << line
92
+ end
93
+ end
94
+
95
+ {
96
+ "title" => title,
97
+ "goal" => goal,
98
+ "steps" => steps,
99
+ "notes" => notes_lines.join("\n").strip
100
+ }
101
+ end
102
+
103
+ def render(plan)
104
+ parts = ["# #{plan["title"] || "Untitled plan"}", ""]
105
+ goal = plan["goal"]
106
+ if goal && !goal.to_s.empty?
107
+ parts << "Goal: #{goal}"
108
+ parts << ""
109
+ end
110
+ parts << "## Steps"
111
+ parts << ""
112
+ (plan["steps"] || []).each do |s|
113
+ box = s["done"] ? "x" : " "
114
+ parts << "- [#{box}] #{(s["text"] || "").to_s.strip}"
115
+ end
116
+ notes = (plan["notes"] || "").strip
117
+ if !notes.empty?
118
+ parts << ""
119
+ parts << "## Notes"
120
+ parts << ""
121
+ parts << notes
122
+ end
123
+ parts.join("\n") + "\n"
124
+ end
125
+
126
+ # ── Public API ─────────────────────────────────────────────
127
+
128
+ def list_plans
129
+ d = plan_dir
130
+ cur = current_name || ""
131
+ out = []
132
+ Dir.glob(File.join(d, "*.md")).sort.each do |path|
133
+ name = File.basename(path)
134
+ parsed = parse(File.read(path, encoding: "utf-8"))
135
+ total = parsed["steps"].size
136
+ done = parsed["steps"].count { |s| s["done"] }
137
+ out << {
138
+ "name" => name,
139
+ "title" => parsed["title"].to_s.empty? ? File.basename(name, ".md") : parsed["title"],
140
+ "steps_total" => total,
141
+ "steps_done" => done,
142
+ "is_current" => name == cur
143
+ }
144
+ end
145
+ out
146
+ end
147
+
148
+ def current_name
149
+ ptr = current_pointer
150
+ return "" unless File.exist?(ptr)
151
+ File.read(ptr, encoding: "utf-8").strip
152
+ end
153
+
154
+ def set_current(name)
155
+ name = name.to_s.strip
156
+ name += ".md" unless name.end_with?(".md")
157
+ path = File.join(plan_dir, name)
158
+ return { "ok" => false, "error" => "No such plan: #{name}" } unless File.exist?(path)
159
+ File.write(current_pointer, name, encoding: "utf-8")
160
+ { "ok" => true, "current" => name }
161
+ end
162
+
163
+ def clear_current
164
+ File.delete(current_pointer) if File.exist?(current_pointer)
165
+ { "ok" => true }
166
+ end
167
+
168
+ def current
169
+ name = current_name
170
+ return { "current" => nil } if name.empty?
171
+ path = File.join(plan_dir, name)
172
+ unless File.exist?(path)
173
+ clear_current
174
+ return { "current" => nil, "warning" => "Current pointer referenced missing file: #{name}" }
175
+ end
176
+ parsed = parse(File.read(path, encoding: "utf-8"))
177
+ indexed = parsed["steps"].each_with_index.map do |s, i|
178
+ { "index" => i, "text" => s["text"], "done" => s["done"] }
179
+ end
180
+ next_step = indexed.find { |s| !s["done"] }
181
+ {
182
+ "current" => name,
183
+ "title" => parsed["title"],
184
+ "goal" => parsed["goal"],
185
+ "steps" => indexed,
186
+ "next_step" => next_step,
187
+ "notes" => parsed["notes"],
188
+ "progress" => {
189
+ "done" => indexed.count { |s| s["done"] },
190
+ "total" => indexed.size
191
+ },
192
+ "execution" => summarise_execution(name)
193
+ }
194
+ end
195
+
196
+ def read(name)
197
+ name = name.to_s
198
+ name += ".md" unless name.end_with?(".md")
199
+ path = File.join(plan_dir, name)
200
+ return { "error" => "No such plan: #{name}" } unless File.exist?(path)
201
+ parse(File.read(path, encoding: "utf-8")).merge("name" => name)
202
+ end
203
+
204
+ def create(title, goal: "", steps: nil, make_current: true)
205
+ title = title.to_s.strip
206
+ return { "ok" => false, "error" => "title is required" } if title.empty?
207
+ name = "#{slugify(title)}.md"
208
+ path = File.join(plan_dir, name)
209
+ if File.exist?(path)
210
+ return {
211
+ "ok" => false,
212
+ "error" => "Plan already exists: #{name}. Pick a different title or edit the existing one."
213
+ }
214
+ end
215
+ plan = {
216
+ "title" => title,
217
+ "goal" => goal.to_s.strip,
218
+ "steps" => (steps || []).map { |s| s.to_s.strip }.reject(&:empty?).map { |s| { "text" => s, "done" => false } },
219
+ "notes" => ""
220
+ }
221
+ File.write(path, render(plan), encoding: "utf-8")
222
+ File.write(current_pointer, name, encoding: "utf-8") if make_current
223
+ { "ok" => true, "name" => name, "title" => title, "is_current" => make_current }
224
+ end
225
+
226
+ def complete_step(index, name = "")
227
+ target = load_for_mutation(name)
228
+ return target if target.is_a?(Hash) && target["ok"] == false
229
+ path, plan = target
230
+ steps = plan["steps"]
231
+ if index.negative? || index >= steps.size
232
+ return { "ok" => false, "error" => "Step index #{index} out of range (0..#{steps.size - 1})" }
233
+ end
234
+ steps[index]["done"] = true
235
+ File.write(path, render(plan), encoding: "utf-8")
236
+ remaining = steps.each_with_index.reject { |s, _| s["done"] }.map { |_, i| i }
237
+ {
238
+ "ok" => true,
239
+ "completed" => steps[index]["text"],
240
+ "remaining" => remaining.size,
241
+ "next_step" => remaining.empty? ? nil : steps[remaining.first]["text"]
242
+ }
243
+ end
244
+
245
+ def uncomplete_step(index, name = "")
246
+ target = load_for_mutation(name)
247
+ return target if target.is_a?(Hash) && target["ok"] == false
248
+ path, plan = target
249
+ steps = plan["steps"]
250
+ if index.negative? || index >= steps.size
251
+ return { "ok" => false, "error" => "Step index #{index} out of range" }
252
+ end
253
+ steps[index]["done"] = false
254
+ File.write(path, render(plan), encoding: "utf-8")
255
+ { "ok" => true, "step" => steps[index]["text"] }
256
+ end
257
+
258
+ def add_step(text, name = "")
259
+ text = text.to_s.strip
260
+ return { "ok" => false, "error" => "text is required" } if text.empty?
261
+ target = load_for_mutation(name)
262
+ return target if target.is_a?(Hash) && target["ok"] == false
263
+ path, plan = target
264
+ plan["steps"] << { "text" => text, "done" => false }
265
+ File.write(path, render(plan), encoding: "utf-8")
266
+ { "ok" => true, "step" => text, "index" => plan["steps"].size - 1 }
267
+ end
268
+
269
+ def append_note(text, name = "")
270
+ text = text.to_s.strip
271
+ return { "ok" => false, "error" => "text is required" } if text.empty?
272
+ target = load_for_mutation(name)
273
+ return target if target.is_a?(Hash) && target["ok"] == false
274
+ path, plan = target
275
+ existing = (plan["notes"] || "").strip
276
+ stamp = Time.now.strftime("%Y-%m-%d %H:%M")
277
+ plan["notes"] = (existing + "\n- [#{stamp}] #{text}").strip
278
+ File.write(path, render(plan), encoding: "utf-8")
279
+ { "ok" => true, "appended" => text }
280
+ end
281
+
282
+ # ── Execution ledger ───────────────────────────────────────
283
+
284
+ def ledger_path(name = "")
285
+ name = name.to_s
286
+ name = current_name if name.empty?
287
+ return nil if name.empty?
288
+ name += ".md" unless name.end_with?(".md")
289
+ File.join(plan_dir, "#{name[0..-4]}.log.json")
290
+ end
291
+
292
+ def record_action(action, path, note: "")
293
+ lp = ledger_path
294
+ return nil if lp.nil?
295
+ entries = []
296
+ if File.exist?(lp)
297
+ begin
298
+ entries = JSON.parse(File.read(lp, encoding: "utf-8"))
299
+ rescue StandardError
300
+ entries = []
301
+ end
302
+ end
303
+ entries << {
304
+ "t" => Time.now.to_i,
305
+ "action" => action,
306
+ "path" => path,
307
+ "note" => note
308
+ }
309
+ entries = entries.last(500) if entries.size > 500
310
+ begin
311
+ File.write(lp, JSON.pretty_generate(entries), encoding: "utf-8")
312
+ rescue StandardError
313
+ # best-effort
314
+ end
315
+ nil
316
+ end
317
+
318
+ def summarise_execution(name = "")
319
+ lp = ledger_path(name)
320
+ empty = { "created" => [], "patched" => [], "migrations" => [], "total" => 0 }
321
+ return empty if lp.nil? || !File.exist?(lp)
322
+ begin
323
+ entries = JSON.parse(File.read(lp, encoding: "utf-8"))
324
+ rescue StandardError
325
+ return empty
326
+ end
327
+ created = []
328
+ patched = []
329
+ migrations = []
330
+ entries.each do |e|
331
+ p = e["path"]
332
+ next if p.nil?
333
+ bucket = case e["action"]
334
+ when "migration" then migrations
335
+ when "created" then created
336
+ when "patched" then patched
337
+ end
338
+ bucket << p if bucket && !bucket.include?(p)
339
+ end
340
+ {
341
+ "created" => created.last(20),
342
+ "patched" => patched.last(20),
343
+ "migrations" => migrations.last(20),
344
+ "total" => entries.size
345
+ }
346
+ end
347
+
348
+ # ── AI flesh-out ──────────────────────────────────────────
349
+
350
+ def flesh(name = "", prompt = "")
351
+ target = (name.to_s.strip.empty? ? current_name : name.to_s.strip)
352
+ return { "ok" => false, "error" => "No current plan and no name given" } if target.empty?
353
+ current_plan = read(target)
354
+ return { "ok" => false, "error" => current_plan["error"] } if current_plan["error"]
355
+
356
+ existing = (current_plan["steps"] || []).map { |s| s["text"].to_s }
357
+ title = current_plan["title"].to_s.empty? ? target : current_plan["title"]
358
+ goal = current_plan["goal"].to_s
359
+
360
+ system_prompt = (
361
+ "You are Tina4, a coding planner embedded in the Tina4 dev " \
362
+ "admin. Return ONLY a JSON array of short imperative step " \
363
+ "strings (no prose, no code-fences, no numbering). 3-8 steps, " \
364
+ "each referencing concrete files/routes/migrations. Example: " \
365
+ '["Create src/orm/Duck.rb with id/name/sighted_at", ' \
366
+ '"Add migration 001_create_ducks.sql", ' \
367
+ '"Add GET/POST/PUT/DELETE /api/ducks routes in src/routes/ducks.rb"]'
368
+ )
369
+ user_parts = ["Plan title: #{title}"]
370
+ user_parts << "Goal: #{goal}" unless goal.empty?
371
+ user_parts << "Existing steps (don't repeat):\n- " + existing.join("\n- ") unless existing.empty?
372
+ user_parts << "Extra context from caller: #{prompt}" unless prompt.to_s.empty?
373
+ user_parts << "Reply with ONLY the JSON array — no explanation, no markdown fences."
374
+
375
+ ai_url = ENV.fetch("TINA4_AI_URL", "http://localhost:11437/api/chat")
376
+ ai_model = ENV.fetch("TINA4_AI_MODEL", "qwen2.5-coder:14b")
377
+
378
+ reply = begin
379
+ uri = URI.parse(ai_url)
380
+ http = Net::HTTP.new(uri.host, uri.port)
381
+ http.use_ssl = (uri.scheme == "https")
382
+ http.open_timeout = 10
383
+ http.read_timeout = 120
384
+ req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
385
+ req.body = JSON.generate({
386
+ "model" => ai_model,
387
+ "stream" => false,
388
+ "messages" => [
389
+ { "role" => "system", "content" => system_prompt },
390
+ { "role" => "user", "content" => user_parts.join("\n\n") }
391
+ ]
392
+ })
393
+ resp = http.request(req)
394
+ body = JSON.parse(resp.body)
395
+ (body["message"].is_a?(Hash) ? body["message"]["content"] : nil) || body["response"] || ""
396
+ rescue StandardError => e
397
+ return { "ok" => false, "error" => "AI backend unreachable: #{e.message}" }
398
+ end
399
+
400
+ body = reply.to_s.strip
401
+ if body.start_with?("```")
402
+ body = body.gsub(/\A`+|`+\z/, "")
403
+ body = body[4..].to_s.strip if body.downcase.start_with?("json")
404
+ body = body.strip
405
+ end
406
+
407
+ proposed = []
408
+ begin
409
+ parsed = JSON.parse(body)
410
+ proposed = parsed.map { |x| x.to_s.strip }.reject(&:empty?) if parsed.is_a?(Array)
411
+ rescue StandardError
412
+ reply.split("\n").each do |line|
413
+ m = line.match(/\A\s*(?:[-*]|\d+[.)])\s+(.+?)\s*\z/)
414
+ proposed << m[1].strip if m
415
+ end
416
+ end
417
+
418
+ if proposed.empty?
419
+ return { "ok" => false, "error" => "AI returned no usable steps", "raw_reply" => reply.to_s[0, 400] }
420
+ end
421
+
422
+ existing_lc = existing.map(&:downcase).to_set rescue existing.map(&:downcase)
423
+ existing_lc = Set.new(existing_lc) if existing_lc.is_a?(Array)
424
+ added = []
425
+ proposed.each do |step|
426
+ next if existing_lc.include?(step.downcase)
427
+ res = add_step(step, target)
428
+ if res["ok"]
429
+ added << step
430
+ existing_lc << step.downcase
431
+ end
432
+ end
433
+
434
+ {
435
+ "ok" => true,
436
+ "plan" => target,
437
+ "added" => added,
438
+ "added_count" => added.size,
439
+ "proposed_count" => proposed.size,
440
+ "plan_after" => read(target)
441
+ }
442
+ end
443
+
444
+ def archive(name = "")
445
+ target = name.to_s.strip.empty? ? current_name : name.to_s.strip
446
+ return { "ok" => false, "error" => "No current plan and no name given" } if target.empty?
447
+ target += ".md" unless target.end_with?(".md")
448
+ src = File.join(plan_dir, target)
449
+ return { "ok" => false, "error" => "No such plan: #{target}" } unless File.exist?(src)
450
+ dest = File.join(archive_dir, target)
451
+ dest = File.join(archive_dir, "#{Time.now.to_i}-#{target}") if File.exist?(dest)
452
+ File.rename(src, dest)
453
+ clear_current if current_name == target
454
+ { "ok" => true, "archived_to" => dest.sub("#{project_root}/", "") }
455
+ end
456
+
457
+ private
458
+
459
+ def load_for_mutation(name)
460
+ name = current_name if name.to_s.empty?
461
+ return { "ok" => false, "error" => "No current plan and no name given" } if name.empty?
462
+ name += ".md" unless name.end_with?(".md")
463
+ path = File.join(plan_dir, name)
464
+ return { "ok" => false, "error" => "No such plan: #{name}" } unless File.exist?(path)
465
+ [path, parse(File.read(path, encoding: "utf-8"))]
466
+ end
467
+ end
468
+ end
469
+ end
470
+
471
+ require "set"