tina4ruby 3.11.35 → 3.11.36

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.
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"