Module: Tina4::Plan

Defined in:
lib/tina4/plan.rb

Constant Summary collapse

PLAN_DIR =
"plan"
CURRENT_FILE =
".current"
ARCHIVE_SUBDIR =
"done"
STEP_RE =
/\A\s*[-*]\s*\[(?<box>[ xX])\]\s*(?<text>.+?)\s*\z/.freeze

Class Method Summary collapse

Class Method Details

.add_step(text, name = "") ⇒ Object



258
259
260
261
262
263
264
265
266
267
# File 'lib/tina4/plan.rb', line 258

def add_step(text, name = "")
  text = text.to_s.strip
  return { "ok" => false, "error" => "text is required" } if text.empty?
  target = load_for_mutation(name)
  return target if target.is_a?(Hash) && target["ok"] == false
  path, plan = target
  plan["steps"] << { "text" => text, "done" => false }
  File.write(path, render(plan), encoding: "utf-8")
  { "ok" => true, "step" => text, "index" => plan["steps"].size - 1 }
end

.append_note(text, name = "") ⇒ Object



269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/tina4/plan.rb', line 269

def append_note(text, name = "")
  text = text.to_s.strip
  return { "ok" => false, "error" => "text is required" } if text.empty?
  target = load_for_mutation(name)
  return target if target.is_a?(Hash) && target["ok"] == false
  path, plan = target
  existing = (plan["notes"] || "").strip
  stamp = Time.now.strftime("%Y-%m-%d %H:%M")
  plan["notes"] = (existing + "\n- [#{stamp}] #{text}").strip
  File.write(path, render(plan), encoding: "utf-8")
  { "ok" => true, "appended" => text }
end

.archive(name = "") ⇒ Object



444
445
446
447
448
449
450
451
452
453
454
455
# File 'lib/tina4/plan.rb', line 444

def archive(name = "")
  target = name.to_s.strip.empty? ? current_name : name.to_s.strip
  return { "ok" => false, "error" => "No current plan and no name given" } if target.empty?
  target += ".md" unless target.end_with?(".md")
  src = File.join(plan_dir, target)
  return { "ok" => false, "error" => "No such plan: #{target}" } unless File.exist?(src)
  dest = File.join(archive_dir, target)
  dest = File.join(archive_dir, "#{Time.now.to_i}-#{target}") if File.exist?(dest)
  File.rename(src, dest)
  clear_current if current_name == target
  { "ok" => true, "archived_to" => dest.sub("#{project_root}/", "") }
end

.archive_dirObject



42
43
44
45
46
# File 'lib/tina4/plan.rb', line 42

def archive_dir
  p = File.join(plan_dir, ARCHIVE_SUBDIR)
  FileUtils.mkdir_p(p)
  p
end

.clear_currentObject



163
164
165
166
# File 'lib/tina4/plan.rb', line 163

def clear_current
  File.delete(current_pointer) if File.exist?(current_pointer)
  { "ok" => true }
end

.complete_step(index, name = "") ⇒ Object



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/tina4/plan.rb', line 226

def complete_step(index, name = "")
  target = load_for_mutation(name)
  return target if target.is_a?(Hash) && target["ok"] == false
  path, plan = target
  steps = plan["steps"]
  if index.negative? || index >= steps.size
    return { "ok" => false, "error" => "Step index #{index} out of range (0..#{steps.size - 1})" }
  end
  steps[index]["done"] = true
  File.write(path, render(plan), encoding: "utf-8")
  remaining = steps.each_with_index.reject { |s, _| s["done"] }.map { |_, i| i }
  {
    "ok"        => true,
    "completed" => steps[index]["text"],
    "remaining" => remaining.size,
    "next_step" => remaining.empty? ? nil : steps[remaining.first]["text"]
  }
end

.create(title, goal: "", steps: nil, make_current: true) ⇒ Object



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/tina4/plan.rb', line 204

def create(title, goal: "", steps: nil, make_current: true)
  title = title.to_s.strip
  return { "ok" => false, "error" => "title is required" } if title.empty?
  name = "#{slugify(title)}.md"
  path = File.join(plan_dir, name)
  if File.exist?(path)
    return {
      "ok"    => false,
      "error" => "Plan already exists: #{name}. Pick a different title or edit the existing one."
    }
  end
  plan = {
    "title" => title,
    "goal"  => goal.to_s.strip,
    "steps" => (steps || []).map { |s| s.to_s.strip }.reject(&:empty?).map { |s| { "text" => s, "done" => false } },
    "notes" => ""
  }
  File.write(path, render(plan), encoding: "utf-8")
  File.write(current_pointer, name, encoding: "utf-8") if make_current
  { "ok" => true, "name" => name, "title" => title, "is_current" => make_current }
end

.currentObject



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/tina4/plan.rb', line 168

def current
  name = current_name
  return { "current" => nil } if name.empty?
  path = File.join(plan_dir, name)
  unless File.exist?(path)
    clear_current
    return { "current" => nil, "warning" => "Current pointer referenced missing file: #{name}" }
  end
  parsed = parse(File.read(path, encoding: "utf-8"))
  indexed = parsed["steps"].each_with_index.map do |s, i|
    { "index" => i, "text" => s["text"], "done" => s["done"] }
  end
  next_step = indexed.find { |s| !s["done"] }
  {
    "current"   => name,
    "title"     => parsed["title"],
    "goal"      => parsed["goal"],
    "steps"     => indexed,
    "next_step" => next_step,
    "notes"     => parsed["notes"],
    "progress"  => {
      "done"  => indexed.count { |s| s["done"] },
      "total" => indexed.size
    },
    "execution" => summarise_execution(name)
  }
end

.current_nameObject



148
149
150
151
152
# File 'lib/tina4/plan.rb', line 148

def current_name
  ptr = current_pointer
  return "" unless File.exist?(ptr)
  File.read(ptr, encoding: "utf-8").strip
end

.current_pointerObject



38
39
40
# File 'lib/tina4/plan.rb', line 38

def current_pointer
  File.join(plan_dir, CURRENT_FILE)
end

.flesh(name = "", prompt = "") ⇒ Object

── AI flesh-out ──────────────────────────────────────────



350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/tina4/plan.rb', line 350

def flesh(name = "", prompt = "")
  target = (name.to_s.strip.empty? ? current_name : name.to_s.strip)
  return { "ok" => false, "error" => "No current plan and no name given" } if target.empty?
  current_plan = read(target)
  return { "ok" => false, "error" => current_plan["error"] } if current_plan["error"]

  existing = (current_plan["steps"] || []).map { |s| s["text"].to_s }
  title = current_plan["title"].to_s.empty? ? target : current_plan["title"]
  goal = current_plan["goal"].to_s

  system_prompt = (
    "You are Tina4, a coding planner embedded in the Tina4 dev " \
    "admin. Return ONLY a JSON array of short imperative step " \
    "strings (no prose, no code-fences, no numbering). 3-8 steps, " \
    "each referencing concrete files/routes/migrations. Example: " \
    '["Create src/orm/Duck.rb with id/name/sighted_at", ' \
    '"Add migration 001_create_ducks.sql", ' \
    '"Add GET/POST/PUT/DELETE /api/ducks routes in src/routes/ducks.rb"]'
  )
  user_parts = ["Plan title: #{title}"]
  user_parts << "Goal: #{goal}" unless goal.empty?
  user_parts << "Existing steps (don't repeat):\n- " + existing.join("\n- ") unless existing.empty?
  user_parts << "Extra context from caller: #{prompt}" unless prompt.to_s.empty?
  user_parts << "Reply with ONLY the JSON array — no explanation, no markdown fences."

  ai_url = ENV.fetch("TINA4_AI_URL", "http://localhost:11437/api/chat")
  ai_model = ENV.fetch("TINA4_AI_MODEL", "qwen2.5-coder:14b")

  reply = begin
    uri = URI.parse(ai_url)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = (uri.scheme == "https")
    http.open_timeout = 10
    http.read_timeout = 120
    req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
    req.body = JSON.generate({
      "model"    => ai_model,
      "stream"   => false,
      "messages" => [
        { "role" => "system", "content" => system_prompt },
        { "role" => "user",   "content" => user_parts.join("\n\n") }
      ]
    })
    resp = http.request(req)
    body = JSON.parse(resp.body)
    (body["message"].is_a?(Hash) ? body["message"]["content"] : nil) || body["response"] || ""
  rescue StandardError => e
    return { "ok" => false, "error" => "AI backend unreachable: #{e.message}" }
  end

  body = reply.to_s.strip
  if body.start_with?("```")
    body = body.gsub(/\A`+|`+\z/, "")
    body = body[4..].to_s.strip if body.downcase.start_with?("json")
    body = body.strip
  end

  proposed = []
  begin
    parsed = JSON.parse(body)
    proposed = parsed.map { |x| x.to_s.strip }.reject(&:empty?) if parsed.is_a?(Array)
  rescue StandardError
    reply.split("\n").each do |line|
      m = line.match(/\A\s*(?:[-*]|\d+[.)])\s+(.+?)\s*\z/)
      proposed << m[1].strip if m
    end
  end

  if proposed.empty?
    return { "ok" => false, "error" => "AI returned no usable steps", "raw_reply" => reply.to_s[0, 400] }
  end

  existing_lc = existing.map(&:downcase).to_set rescue existing.map(&:downcase)
  existing_lc = Set.new(existing_lc) if existing_lc.is_a?(Array)
  added = []
  proposed.each do |step|
    next if existing_lc.include?(step.downcase)
    res = add_step(step, target)
    if res["ok"]
      added << step
      existing_lc << step.downcase
    end
  end

  {
    "ok"              => true,
    "plan"            => target,
    "added"           => added,
    "added_count"     => added.size,
    "proposed_count"  => proposed.size,
    "plan_after"      => read(target)
  }
end

.ledger_path(name = "") ⇒ Object

── Execution ledger ───────────────────────────────────────



284
285
286
287
288
289
290
# File 'lib/tina4/plan.rb', line 284

def ledger_path(name = "")
  name = name.to_s
  name = current_name if name.empty?
  return nil if name.empty?
  name += ".md" unless name.end_with?(".md")
  File.join(plan_dir, "#{name[0..-4]}.log.json")
end

.list_plansObject

── Public API ─────────────────────────────────────────────



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/tina4/plan.rb', line 128

def list_plans
  d = plan_dir
  cur = current_name || ""
  out = []
  Dir.glob(File.join(d, "*.md")).sort.each do |path|
    name = File.basename(path)
    parsed = parse(File.read(path, encoding: "utf-8"))
    total = parsed["steps"].size
    done  = parsed["steps"].count { |s| s["done"] }
    out << {
      "name"        => name,
      "title"       => parsed["title"].to_s.empty? ? File.basename(name, ".md") : parsed["title"],
      "steps_total" => total,
      "steps_done"  => done,
      "is_current"  => name == cur
    }
  end
  out
end

.parse(text) ⇒ Object

── Parse / render ─────────────────────────────────────────



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/tina4/plan.rb', line 56

def parse(text)
  lines = text.to_s.split("\n", -1)
  title = ""
  goal  = ""
  steps = []
  notes_lines = []
  section = nil # :steps | :notes | :other | nil

  lines.each do |raw|
    line = raw.sub(/[[:space:]]+\z/, "")
    if title.empty? && line.start_with?("# ")
      title = line[2..].to_s.strip
      next
    end
    low = line.strip.downcase
    if low.start_with?("goal:") && goal.empty?
      goal = line.split(":", 2)[1].to_s.strip
      next
    end
    if low == "## steps"
      section = :steps
      next
    end
    if low == "## notes"
      section = :notes
      next
    end
    if line.start_with?("## ")
      section = :other
      next
    end
    if section == :steps
      m = STEP_RE.match(line)
      steps << { "text" => m[:text].strip, "done" => m[:box].downcase == "x" } if m
    elsif section == :notes && !line.strip.empty?
      notes_lines << line
    end
  end

  {
    "title" => title,
    "goal"  => goal,
    "steps" => steps,
    "notes" => notes_lines.join("\n").strip
  }
end

.plan_dirObject



32
33
34
35
36
# File 'lib/tina4/plan.rb', line 32

def plan_dir
  p = File.join(project_root, PLAN_DIR)
  FileUtils.mkdir_p(p)
  p
end

.project_rootObject

── Paths ──────────────────────────────────────────────────



28
29
30
# File 'lib/tina4/plan.rb', line 28

def project_root
  File.expand_path(Dir.pwd)
end

.read(name) ⇒ Object



196
197
198
199
200
201
202
# File 'lib/tina4/plan.rb', line 196

def read(name)
  name = name.to_s
  name += ".md" unless name.end_with?(".md")
  path = File.join(plan_dir, name)
  return { "error" => "No such plan: #{name}" } unless File.exist?(path)
  parse(File.read(path, encoding: "utf-8")).merge("name" => name)
end

.record_action(action, path, note: "") ⇒ Object



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/tina4/plan.rb', line 292

def record_action(action, path, note: "")
  lp = ledger_path
  return nil if lp.nil?
  entries = []
  if File.exist?(lp)
    begin
      entries = JSON.parse(File.read(lp, encoding: "utf-8"))
    rescue StandardError
      entries = []
    end
  end
  entries << {
    "t"      => Time.now.to_i,
    "action" => action,
    "path"   => path,
    "note"   => note
  }
  entries = entries.last(500) if entries.size > 500
  begin
    File.write(lp, JSON.pretty_generate(entries), encoding: "utf-8")
  rescue StandardError
    # best-effort
  end
  nil
end

.render(plan) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/tina4/plan.rb', line 103

def render(plan)
  parts = ["# #{plan["title"] || "Untitled plan"}", ""]
  goal = plan["goal"]
  if goal && !goal.to_s.empty?
    parts << "Goal: #{goal}"
    parts << ""
  end
  parts << "## Steps"
  parts << ""
  (plan["steps"] || []).each do |s|
    box = s["done"] ? "x" : " "
    parts << "- [#{box}] #{(s["text"] || "").to_s.strip}"
  end
  notes = (plan["notes"] || "").strip
  if !notes.empty?
    parts << ""
    parts << "## Notes"
    parts << ""
    parts << notes
  end
  parts.join("\n") + "\n"
end

.set_current(name) ⇒ Object



154
155
156
157
158
159
160
161
# File 'lib/tina4/plan.rb', line 154

def set_current(name)
  name = name.to_s.strip
  name += ".md" unless name.end_with?(".md")
  path = File.join(plan_dir, name)
  return { "ok" => false, "error" => "No such plan: #{name}" } unless File.exist?(path)
  File.write(current_pointer, name, encoding: "utf-8")
  { "ok" => true, "current" => name }
end

.slugify(title) ⇒ Object



48
49
50
51
52
# File 'lib/tina4/plan.rb', line 48

def slugify(title)
  slug = title.to_s.strip.downcase.gsub(/[^a-z0-9_\-]+/, "-").gsub(/\A-+|-+\z/, "")
  slug = slug[0, 80]
  slug.empty? ? "plan-#{Time.now.to_i}" : slug
end

.summarise_execution(name = "") ⇒ Object



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/tina4/plan.rb', line 318

def summarise_execution(name = "")
  lp = ledger_path(name)
  empty = { "created" => [], "patched" => [], "migrations" => [], "total" => 0 }
  return empty if lp.nil? || !File.exist?(lp)
  begin
    entries = JSON.parse(File.read(lp, encoding: "utf-8"))
  rescue StandardError
    return empty
  end
  created = []
  patched = []
  migrations = []
  entries.each do |e|
    p = e["path"]
    next if p.nil?
    bucket = case e["action"]
             when "migration" then migrations
             when "created"   then created
             when "patched"   then patched
             end
    bucket << p if bucket && !bucket.include?(p)
  end
  {
    "created"    => created.last(20),
    "patched"    => patched.last(20),
    "migrations" => migrations.last(20),
    "total"      => entries.size
  }
end

.uncomplete_step(index, name = "") ⇒ Object



245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/tina4/plan.rb', line 245

def uncomplete_step(index, name = "")
  target = load_for_mutation(name)
  return target if target.is_a?(Hash) && target["ok"] == false
  path, plan = target
  steps = plan["steps"]
  if index.negative? || index >= steps.size
    return { "ok" => false, "error" => "Step index #{index} out of range" }
  end
  steps[index]["done"] = false
  File.write(path, render(plan), encoding: "utf-8")
  { "ok" => true, "step" => steps[index]["text"] }
end