tina4-nodejs 3.11.14 → 3.11.16
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.
- package/package.json +1 -1
- package/packages/core/public/js/tina4-dev-admin.js +937 -238
- package/packages/core/public/js/tina4-dev-admin.min.js +993 -209
- package/packages/core/src/devAdmin.ts +487 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/mcp.ts +343 -0
- package/packages/core/src/plan.ts +522 -0
- package/packages/core/src/projectIndex.ts +440 -0
- package/packages/frond/src/engine.ts +86 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project plan management — persistent, human-readable task state.
|
|
3
|
+
*
|
|
4
|
+
* Ported from tina4_python/dev_admin/plan.py (master reference).
|
|
5
|
+
* A plan is a markdown file under plan/ at the project root with sections
|
|
6
|
+
* for title, goal, steps (checkboxes), and notes. Exactly one plan is
|
|
7
|
+
* "current" at a time, recorded in plan/.current.
|
|
8
|
+
*
|
|
9
|
+
* Byte-for-byte compatible with Python's plan/*.md storage.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
|
|
15
|
+
const PLAN_DIR = "plan";
|
|
16
|
+
const CURRENT_FILE = ".current";
|
|
17
|
+
const ARCHIVE_SUBDIR = "done";
|
|
18
|
+
|
|
19
|
+
export interface PlanStep {
|
|
20
|
+
text: string;
|
|
21
|
+
done: boolean;
|
|
22
|
+
index?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ParsedPlan {
|
|
26
|
+
title: string;
|
|
27
|
+
goal: string;
|
|
28
|
+
steps: PlanStep[];
|
|
29
|
+
notes: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PlanSummary {
|
|
33
|
+
name: string;
|
|
34
|
+
title: string;
|
|
35
|
+
steps_total: number;
|
|
36
|
+
steps_done: number;
|
|
37
|
+
is_current: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExecutionSummary {
|
|
41
|
+
created: string[];
|
|
42
|
+
patched: string[];
|
|
43
|
+
migrations: string[];
|
|
44
|
+
total: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CurrentPlan {
|
|
48
|
+
current: string | null;
|
|
49
|
+
title?: string;
|
|
50
|
+
goal?: string;
|
|
51
|
+
steps?: PlanStep[];
|
|
52
|
+
next_step?: PlanStep | null;
|
|
53
|
+
notes?: string;
|
|
54
|
+
progress?: { done: number; total: number };
|
|
55
|
+
execution?: ExecutionSummary;
|
|
56
|
+
warning?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const STEP_RE = /^\s*[-*]\s*\[([ xX])\]\s*(.+?)\s*$/;
|
|
60
|
+
|
|
61
|
+
function projectRoot(): string {
|
|
62
|
+
return path.resolve(process.cwd());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function planDir(): string {
|
|
66
|
+
const p = path.join(projectRoot(), PLAN_DIR);
|
|
67
|
+
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
|
68
|
+
return p;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function currentPointer(): string {
|
|
72
|
+
return path.join(planDir(), CURRENT_FILE);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function archiveDir(): string {
|
|
76
|
+
const p = path.join(planDir(), ARCHIVE_SUBDIR);
|
|
77
|
+
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function slugify(title: string): string {
|
|
82
|
+
const slug = (title || "").trim().toLowerCase()
|
|
83
|
+
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
|
84
|
+
.replace(/^-+|-+$/g, "");
|
|
85
|
+
return slug.slice(0, 80) || `plan-${Math.floor(Date.now() / 1000)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parse(text: string): ParsedPlan {
|
|
89
|
+
const lines = text.split(/\r?\n/);
|
|
90
|
+
let title = "";
|
|
91
|
+
let goal = "";
|
|
92
|
+
const steps: PlanStep[] = [];
|
|
93
|
+
const notesLines: string[] = [];
|
|
94
|
+
let section: "steps" | "notes" | "other" | null = null;
|
|
95
|
+
|
|
96
|
+
for (const raw of lines) {
|
|
97
|
+
const line = raw.replace(/\s+$/, "");
|
|
98
|
+
if (!title && line.startsWith("# ")) {
|
|
99
|
+
title = line.slice(2).trim();
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const low = line.trim().toLowerCase();
|
|
103
|
+
if (low.startsWith("goal:") && !goal) {
|
|
104
|
+
goal = line.split(":").slice(1).join(":").trim();
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (low === "## steps") {
|
|
108
|
+
section = "steps";
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (low === "## notes") {
|
|
112
|
+
section = "notes";
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (line.startsWith("## ")) {
|
|
116
|
+
section = "other";
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (section === "steps") {
|
|
120
|
+
const m = line.match(STEP_RE);
|
|
121
|
+
if (m) {
|
|
122
|
+
steps.push({ text: m[2].trim(), done: m[1].toLowerCase() === "x" });
|
|
123
|
+
}
|
|
124
|
+
} else if (section === "notes" && line.trim()) {
|
|
125
|
+
notesLines.push(line);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { title, goal, steps, notes: notesLines.join("\n").trim() };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function render(planObj: ParsedPlan): string {
|
|
133
|
+
const parts: string[] = [`# ${planObj.title || "Untitled plan"}`, ""];
|
|
134
|
+
if (planObj.goal) {
|
|
135
|
+
parts.push(`Goal: ${planObj.goal}`, "");
|
|
136
|
+
}
|
|
137
|
+
parts.push("## Steps", "");
|
|
138
|
+
for (const step of planObj.steps || []) {
|
|
139
|
+
const box = step.done ? "x" : " ";
|
|
140
|
+
parts.push(`- [${box}] ${(step.text || "").trim()}`);
|
|
141
|
+
}
|
|
142
|
+
const notes = (planObj.notes || "").trim();
|
|
143
|
+
if (notes) {
|
|
144
|
+
parts.push("", "## Notes", "", notes);
|
|
145
|
+
}
|
|
146
|
+
return parts.join("\n") + "\n";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function loadParsed(name: string): { path: string; plan: ParsedPlan } {
|
|
150
|
+
if (!name.endsWith(".md")) name += ".md";
|
|
151
|
+
const p = path.join(planDir(), name);
|
|
152
|
+
if (!fs.existsSync(p)) {
|
|
153
|
+
throw new Error(`No such plan: ${name}`);
|
|
154
|
+
}
|
|
155
|
+
return { path: p, plan: parse(fs.readFileSync(p, "utf-8")) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Plan namespace ────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export const Plan = {
|
|
161
|
+
listPlans(): PlanSummary[] {
|
|
162
|
+
const d = planDir();
|
|
163
|
+
const current = Plan.currentName() || "";
|
|
164
|
+
const out: PlanSummary[] = [];
|
|
165
|
+
const entries = fs.readdirSync(d).filter((f) => f.endsWith(".md")).sort();
|
|
166
|
+
for (const name of entries) {
|
|
167
|
+
const full = path.join(d, name);
|
|
168
|
+
if (!fs.statSync(full).isFile()) continue;
|
|
169
|
+
const parsed = parse(fs.readFileSync(full, "utf-8"));
|
|
170
|
+
const total = parsed.steps.length;
|
|
171
|
+
const done = parsed.steps.filter((s) => s.done).length;
|
|
172
|
+
out.push({
|
|
173
|
+
name,
|
|
174
|
+
title: parsed.title || name.replace(/\.md$/, ""),
|
|
175
|
+
steps_total: total,
|
|
176
|
+
steps_done: done,
|
|
177
|
+
is_current: name === current,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
currentName(): string {
|
|
184
|
+
const ptr = currentPointer();
|
|
185
|
+
if (!fs.existsSync(ptr)) return "";
|
|
186
|
+
return fs.readFileSync(ptr, "utf-8").trim();
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
setCurrent(name: string): { ok: boolean; current?: string; error?: string } {
|
|
190
|
+
name = name.trim();
|
|
191
|
+
if (!name.endsWith(".md")) name += ".md";
|
|
192
|
+
const p = path.join(planDir(), name);
|
|
193
|
+
if (!fs.existsSync(p)) return { ok: false, error: `No such plan: ${name}` };
|
|
194
|
+
fs.writeFileSync(currentPointer(), name, "utf-8");
|
|
195
|
+
return { ok: true, current: name };
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
clearCurrent(): { ok: boolean } {
|
|
199
|
+
const p = currentPointer();
|
|
200
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
201
|
+
return { ok: true };
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
current(): CurrentPlan {
|
|
205
|
+
const name = Plan.currentName();
|
|
206
|
+
if (!name) return { current: null };
|
|
207
|
+
const p = path.join(planDir(), name);
|
|
208
|
+
if (!fs.existsSync(p)) {
|
|
209
|
+
Plan.clearCurrent();
|
|
210
|
+
return { current: null, warning: `Current pointer referenced missing file: ${name}` };
|
|
211
|
+
}
|
|
212
|
+
const parsed = parse(fs.readFileSync(p, "utf-8"));
|
|
213
|
+
const indexedSteps: PlanStep[] = parsed.steps.map((s, i) => ({ index: i, text: s.text, done: s.done }));
|
|
214
|
+
const nextStep = indexedSteps.find((s) => !s.done) || null;
|
|
215
|
+
return {
|
|
216
|
+
current: name,
|
|
217
|
+
title: parsed.title,
|
|
218
|
+
goal: parsed.goal,
|
|
219
|
+
steps: indexedSteps,
|
|
220
|
+
next_step: nextStep,
|
|
221
|
+
notes: parsed.notes,
|
|
222
|
+
progress: {
|
|
223
|
+
done: indexedSteps.filter((s) => s.done).length,
|
|
224
|
+
total: indexedSteps.length,
|
|
225
|
+
},
|
|
226
|
+
execution: Plan.summariseExecution(name),
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
read(name: string): ParsedPlan & { name?: string; error?: string } {
|
|
231
|
+
if (!name.endsWith(".md")) name += ".md";
|
|
232
|
+
const p = path.join(planDir(), name);
|
|
233
|
+
if (!fs.existsSync(p)) return { title: "", goal: "", steps: [], notes: "", error: `No such plan: ${name}` };
|
|
234
|
+
return { ...parse(fs.readFileSync(p, "utf-8")), name };
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
create(
|
|
238
|
+
title: string,
|
|
239
|
+
goal = "",
|
|
240
|
+
steps: string[] = [],
|
|
241
|
+
makeCurrent = true,
|
|
242
|
+
): { ok: boolean; name?: string; title?: string; is_current?: boolean; error?: string } {
|
|
243
|
+
title = (title || "").trim();
|
|
244
|
+
if (!title) return { ok: false, error: "title is required" };
|
|
245
|
+
const stem = slugify(title);
|
|
246
|
+
const name = `${stem}.md`;
|
|
247
|
+
const p = path.join(planDir(), name);
|
|
248
|
+
if (fs.existsSync(p)) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
error: `Plan already exists: ${name}. Pick a different title or edit the existing one.`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const planObj: ParsedPlan = {
|
|
255
|
+
title,
|
|
256
|
+
goal: goal.trim(),
|
|
257
|
+
steps: (steps || []).filter((s) => s && s.trim()).map((s) => ({ text: s.trim(), done: false })),
|
|
258
|
+
notes: "",
|
|
259
|
+
};
|
|
260
|
+
fs.writeFileSync(p, render(planObj), "utf-8");
|
|
261
|
+
if (makeCurrent) fs.writeFileSync(currentPointer(), name, "utf-8");
|
|
262
|
+
return { ok: true, name, title, is_current: makeCurrent };
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
completeStep(index: number, name = ""): Record<string, unknown> {
|
|
266
|
+
name = name || Plan.currentName();
|
|
267
|
+
if (!name) return { ok: false, error: "No current plan and no name given" };
|
|
268
|
+
let loaded;
|
|
269
|
+
try {
|
|
270
|
+
loaded = loadParsed(name);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
return { ok: false, error: (e as Error).message };
|
|
273
|
+
}
|
|
274
|
+
const { path: p, plan } = loaded;
|
|
275
|
+
if (!(index >= 0 && index < plan.steps.length)) {
|
|
276
|
+
return { ok: false, error: `Step index ${index} out of range (0..${plan.steps.length - 1})` };
|
|
277
|
+
}
|
|
278
|
+
plan.steps[index].done = true;
|
|
279
|
+
fs.writeFileSync(p, render(plan), "utf-8");
|
|
280
|
+
const remaining = plan.steps
|
|
281
|
+
.map((s, i) => ({ s, i }))
|
|
282
|
+
.filter(({ s }) => !s.done);
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
completed: plan.steps[index].text,
|
|
286
|
+
remaining: remaining.length,
|
|
287
|
+
next_step: remaining.length > 0 ? plan.steps[remaining[0].i].text : null,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
uncompleteStep(index: number, name = ""): Record<string, unknown> {
|
|
292
|
+
name = name || Plan.currentName();
|
|
293
|
+
if (!name) return { ok: false, error: "No current plan and no name given" };
|
|
294
|
+
let loaded;
|
|
295
|
+
try {
|
|
296
|
+
loaded = loadParsed(name);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
return { ok: false, error: (e as Error).message };
|
|
299
|
+
}
|
|
300
|
+
const { path: p, plan } = loaded;
|
|
301
|
+
if (!(index >= 0 && index < plan.steps.length)) {
|
|
302
|
+
return { ok: false, error: `Step index ${index} out of range` };
|
|
303
|
+
}
|
|
304
|
+
plan.steps[index].done = false;
|
|
305
|
+
fs.writeFileSync(p, render(plan), "utf-8");
|
|
306
|
+
return { ok: true, step: plan.steps[index].text };
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
addStep(text: string, name = ""): Record<string, unknown> {
|
|
310
|
+
text = (text || "").trim();
|
|
311
|
+
if (!text) return { ok: false, error: "text is required" };
|
|
312
|
+
name = name || Plan.currentName();
|
|
313
|
+
if (!name) return { ok: false, error: "No current plan and no name given" };
|
|
314
|
+
let loaded;
|
|
315
|
+
try {
|
|
316
|
+
loaded = loadParsed(name);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
return { ok: false, error: (e as Error).message };
|
|
319
|
+
}
|
|
320
|
+
const { path: p, plan } = loaded;
|
|
321
|
+
plan.steps.push({ text, done: false });
|
|
322
|
+
fs.writeFileSync(p, render(plan), "utf-8");
|
|
323
|
+
return { ok: true, step: text, index: plan.steps.length - 1 };
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
appendNote(text: string, name = ""): Record<string, unknown> {
|
|
327
|
+
text = (text || "").trim();
|
|
328
|
+
if (!text) return { ok: false, error: "text is required" };
|
|
329
|
+
name = name || Plan.currentName();
|
|
330
|
+
if (!name) return { ok: false, error: "No current plan and no name given" };
|
|
331
|
+
let loaded;
|
|
332
|
+
try {
|
|
333
|
+
loaded = loadParsed(name);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
return { ok: false, error: (e as Error).message };
|
|
336
|
+
}
|
|
337
|
+
const { path: p, plan } = loaded;
|
|
338
|
+
const existing = (plan.notes || "").trim();
|
|
339
|
+
const stamp = new Date().toISOString().slice(0, 16).replace("T", " ");
|
|
340
|
+
plan.notes = (existing + `\n- [${stamp}] ${text}`).trim();
|
|
341
|
+
fs.writeFileSync(p, render(plan), "utf-8");
|
|
342
|
+
return { ok: true, appended: text };
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
// ── Execution ledger ──────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
recordAction(action: string, filePath: string, note = ""): void {
|
|
348
|
+
const lp = ledgerPath();
|
|
349
|
+
if (!lp) return;
|
|
350
|
+
let entries: Array<Record<string, unknown>> = [];
|
|
351
|
+
if (fs.existsSync(lp)) {
|
|
352
|
+
try {
|
|
353
|
+
entries = JSON.parse(fs.readFileSync(lp, "utf-8"));
|
|
354
|
+
if (!Array.isArray(entries)) entries = [];
|
|
355
|
+
} catch {
|
|
356
|
+
entries = [];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
entries.push({
|
|
360
|
+
t: Math.floor(Date.now() / 1000),
|
|
361
|
+
action,
|
|
362
|
+
path: filePath,
|
|
363
|
+
note,
|
|
364
|
+
});
|
|
365
|
+
if (entries.length > 500) entries = entries.slice(-500);
|
|
366
|
+
try {
|
|
367
|
+
fs.writeFileSync(lp, JSON.stringify(entries, null, 2), "utf-8");
|
|
368
|
+
} catch {
|
|
369
|
+
// best-effort
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
summariseExecution(name = ""): ExecutionSummary {
|
|
374
|
+
const lp = ledgerPath(name);
|
|
375
|
+
if (!lp || !fs.existsSync(lp)) {
|
|
376
|
+
return { created: [], patched: [], migrations: [], total: 0 };
|
|
377
|
+
}
|
|
378
|
+
let entries: Array<Record<string, unknown>>;
|
|
379
|
+
try {
|
|
380
|
+
entries = JSON.parse(fs.readFileSync(lp, "utf-8"));
|
|
381
|
+
} catch {
|
|
382
|
+
return { created: [], patched: [], migrations: [], total: 0 };
|
|
383
|
+
}
|
|
384
|
+
const created: string[] = [];
|
|
385
|
+
const patched: string[] = [];
|
|
386
|
+
const migrations: string[] = [];
|
|
387
|
+
for (const e of entries) {
|
|
388
|
+
const action = e.action as string;
|
|
389
|
+
const p = e.path as string;
|
|
390
|
+
if (!p) continue;
|
|
391
|
+
const bucket =
|
|
392
|
+
action === "migration" ? migrations :
|
|
393
|
+
action === "created" ? created :
|
|
394
|
+
action === "patched" ? patched : null;
|
|
395
|
+
if (bucket && !bucket.includes(p)) bucket.push(p);
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
created: created.slice(-20),
|
|
399
|
+
patched: patched.slice(-20),
|
|
400
|
+
migrations: migrations.slice(-20),
|
|
401
|
+
total: entries.length,
|
|
402
|
+
};
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
async flesh(name = "", prompt = ""): Promise<Record<string, unknown>> {
|
|
406
|
+
const target = (name || "").trim() || Plan.currentName();
|
|
407
|
+
if (!target) return { ok: false, error: "No current plan and no name given" };
|
|
408
|
+
const currentPlan = Plan.read(target);
|
|
409
|
+
if (currentPlan.error) return { ok: false, error: currentPlan.error };
|
|
410
|
+
|
|
411
|
+
const existing = (currentPlan.steps || []).map((s) => s.text || "");
|
|
412
|
+
const title = currentPlan.title || target;
|
|
413
|
+
const goal = currentPlan.goal || "";
|
|
414
|
+
|
|
415
|
+
const systemPrompt =
|
|
416
|
+
"You are Tina4, a coding planner embedded in the Tina4 dev " +
|
|
417
|
+
"admin. Return ONLY a JSON array of short imperative step " +
|
|
418
|
+
"strings (no prose, no code-fences, no numbering). 3-8 steps, " +
|
|
419
|
+
"each referencing concrete files/routes/migrations. Example: " +
|
|
420
|
+
'["Create src/orm/Duck.ts with id/name/sighted_at", ' +
|
|
421
|
+
'"Add migration 001_create_ducks.sql", ' +
|
|
422
|
+
'"Add GET/POST/PUT/DELETE /api/ducks routes in ' +
|
|
423
|
+
'src/routes/ducks.ts"]';
|
|
424
|
+
|
|
425
|
+
const userParts: string[] = [`Plan title: ${title}`];
|
|
426
|
+
if (goal) userParts.push(`Goal: ${goal}`);
|
|
427
|
+
if (existing.length) userParts.push("Existing steps (don't repeat):\n- " + existing.join("\n- "));
|
|
428
|
+
if (prompt) userParts.push(`Extra context from caller: ${prompt}`);
|
|
429
|
+
userParts.push("Reply with ONLY the JSON array — no explanation, no markdown fences.");
|
|
430
|
+
|
|
431
|
+
const aiUrl = process.env.TINA4_AI_URL || "http://localhost:11437/api/chat";
|
|
432
|
+
const aiModel = process.env.TINA4_AI_MODEL || "qwen2.5-coder:14b";
|
|
433
|
+
|
|
434
|
+
let result: Record<string, unknown>;
|
|
435
|
+
try {
|
|
436
|
+
const resp = await fetch(aiUrl, {
|
|
437
|
+
method: "POST",
|
|
438
|
+
headers: { "Content-Type": "application/json" },
|
|
439
|
+
body: JSON.stringify({
|
|
440
|
+
model: aiModel,
|
|
441
|
+
stream: false,
|
|
442
|
+
messages: [
|
|
443
|
+
{ role: "system", content: systemPrompt },
|
|
444
|
+
{ role: "user", content: userParts.join("\n\n") },
|
|
445
|
+
],
|
|
446
|
+
}),
|
|
447
|
+
signal: AbortSignal.timeout(120_000),
|
|
448
|
+
});
|
|
449
|
+
result = await resp.json();
|
|
450
|
+
} catch (e) {
|
|
451
|
+
return { ok: false, error: `AI backend unreachable: ${(e as Error).message}` };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const msg = (result.message as Record<string, unknown> | undefined) || {};
|
|
455
|
+
const reply = (msg.content as string) || (result.response as string) || "";
|
|
456
|
+
let body = reply.trim();
|
|
457
|
+
if (body.startsWith("```")) {
|
|
458
|
+
body = body.replace(/^`+/, "").replace(/`+$/, "");
|
|
459
|
+
if (body.toLowerCase().startsWith("json")) body = body.slice(4).trim();
|
|
460
|
+
body = body.trim();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let proposed: string[] = [];
|
|
464
|
+
try {
|
|
465
|
+
const parsed = JSON.parse(body);
|
|
466
|
+
if (Array.isArray(parsed)) {
|
|
467
|
+
proposed = parsed.map((x) => String(x).trim()).filter(Boolean);
|
|
468
|
+
}
|
|
469
|
+
} catch {
|
|
470
|
+
for (const line of reply.split(/\r?\n/)) {
|
|
471
|
+
const m = line.match(/^\s*(?:[-*]|\d+[.)])\s+(.+?)\s*$/);
|
|
472
|
+
if (m) proposed.push(m[1].trim());
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (proposed.length === 0) {
|
|
477
|
+
return { ok: false, error: "AI returned no usable steps", raw_reply: reply.slice(0, 400) };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const existingLc = new Set(existing.map((s) => s.toLowerCase()));
|
|
481
|
+
const added: string[] = [];
|
|
482
|
+
for (const step of proposed) {
|
|
483
|
+
if (existingLc.has(step.toLowerCase())) continue;
|
|
484
|
+
const res = Plan.addStep(step, target);
|
|
485
|
+
if (res.ok) {
|
|
486
|
+
added.push(step);
|
|
487
|
+
existingLc.add(step.toLowerCase());
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
ok: true,
|
|
493
|
+
plan: target,
|
|
494
|
+
added,
|
|
495
|
+
added_count: added.length,
|
|
496
|
+
proposed_count: proposed.length,
|
|
497
|
+
plan_after: Plan.read(target),
|
|
498
|
+
};
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
archive(name = ""): Record<string, unknown> {
|
|
502
|
+
name = name || Plan.currentName();
|
|
503
|
+
if (!name) return { ok: false, error: "No current plan and no name given" };
|
|
504
|
+
if (!name.endsWith(".md")) name += ".md";
|
|
505
|
+
const src = path.join(planDir(), name);
|
|
506
|
+
if (!fs.existsSync(src)) return { ok: false, error: `No such plan: ${name}` };
|
|
507
|
+
let dest = path.join(archiveDir(), name);
|
|
508
|
+
if (fs.existsSync(dest)) {
|
|
509
|
+
dest = path.join(archiveDir(), `${Math.floor(Date.now() / 1000)}-${name}`);
|
|
510
|
+
}
|
|
511
|
+
fs.renameSync(src, dest);
|
|
512
|
+
if (Plan.currentName() === name) Plan.clearCurrent();
|
|
513
|
+
return { ok: true, archived_to: path.relative(projectRoot(), dest) };
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
function ledgerPath(name = ""): string | null {
|
|
518
|
+
name = name || Plan.currentName();
|
|
519
|
+
if (!name) return null;
|
|
520
|
+
if (!name.endsWith(".md")) name += ".md";
|
|
521
|
+
return path.join(planDir(), `${name.slice(0, -3)}.log.json`);
|
|
522
|
+
}
|