scriptonia 0.5.0 → 0.7.0
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/bin/scriptonia.mjs +342 -40
- package/package.json +1 -1
package/bin/scriptonia.mjs
CHANGED
|
@@ -32,7 +32,7 @@ const commands = {
|
|
|
32
32
|
add, ingest: add, push: add,
|
|
33
33
|
plan, plans, comment,
|
|
34
34
|
query, contexts, link,
|
|
35
|
-
status, logout, skill: reinstallSkill, mcp,
|
|
35
|
+
status, credits, logout, skill: reinstallSkill, mcp,
|
|
36
36
|
flow, guide: flow,
|
|
37
37
|
help: async () => usage(),
|
|
38
38
|
};
|
|
@@ -132,13 +132,19 @@ async function flow() {
|
|
|
132
132
|
const row = (n, name, desc, cmd) =>
|
|
133
133
|
console.log(` ${C.v}${n}${C.r} ${C.b}${name.padEnd(7)}${C.r} ${C.dim}${desc.padEnd(34)}${C.r} ${cmd ? C.g + cmd + C.r : ""}`);
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
const cr = o?.credits;
|
|
136
|
+
const creditStr = cr && cr.metered !== false ? ` · ${cr.plan} ${cr.used}/${cr.cap} credits` : "";
|
|
137
|
+
console.log(` ${C.dim}THE LOOP${C.r} ${C.dim}${s.total} signals · ${plansN} plans${creditStr}${C.r}`);
|
|
136
138
|
row("1", "SIGNAL", "feed customer reality (text·md·vtt)", `scriptonia add <files>`);
|
|
137
139
|
row("2", "PLAN", "issue → PLAN.md, sourced & checked", `scriptonia plan "<issue>"`);
|
|
138
140
|
row("3", "BUILD", "your agent executes PLAN.md → PR", `claude "execute PLAN.md"`);
|
|
139
141
|
row("4", "REFINE", "a note regenerates the plan", `scriptonia comment plan/<slug> "…"`);
|
|
140
142
|
|
|
141
|
-
console.log(`\n ${C.b}NEXT${C.r} ${nextStep(s, plansN, recentPlan, repo, C)}
|
|
143
|
+
console.log(`\n ${C.b}NEXT${C.r} ${nextStep(s, plansN, recentPlan, repo, C)}`);
|
|
144
|
+
if (cr && cr.metered !== false && cr.available <= 0) {
|
|
145
|
+
console.log(` ${C.w}Out of credits${C.r} ${C.dim}— ${cr.topup}${C.r}`);
|
|
146
|
+
}
|
|
147
|
+
console.log("");
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
function nextStep(s, plansN, recentPlan, repo, C) {
|
|
@@ -155,10 +161,11 @@ function nextStep(s, plansN, recentPlan, repo, C) {
|
|
|
155
161
|
async function add(args) {
|
|
156
162
|
const cfg = activeCreds();
|
|
157
163
|
const flags = parseFlags(args);
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
164
|
+
const items = args.filter((a) => !a.startsWith("--"));
|
|
165
|
+
if (items.length === 0) {
|
|
160
166
|
fail(
|
|
161
|
-
'usage: scriptonia add <file...>
|
|
167
|
+
'usage: scriptonia add <file...> e.g. scriptonia add slack.txt notes.md call.vtt\n' +
|
|
168
|
+
' scriptonia add "Issue #42: …" inline signal — also analyzed against the brain → context.md\n' +
|
|
162
169
|
" flags: --source slack|ticket|sales_call|voice_note|founder_note|email|notion|github\n" +
|
|
163
170
|
" --segment enterprise|mid_market|smb|free\n" +
|
|
164
171
|
" pipe: cat thread.txt | scriptonia add -",
|
|
@@ -166,21 +173,30 @@ async function add(args) {
|
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
const C = colors();
|
|
176
|
+
// A single non-file argument with whitespace is inline signal text (an issue,
|
|
177
|
+
// a pasted message). A bare word that isn't a file stays a "not found" error
|
|
178
|
+
// so typo'd filenames don't get silently ingested as one-word signals.
|
|
179
|
+
const inline = items.length === 1 && items[0] !== "-" && !fs.existsSync(items[0]) && /\s/.test(items[0]);
|
|
169
180
|
let created = 0, dup = 0, failed = 0;
|
|
181
|
+
let inlineSignalId = null;
|
|
170
182
|
|
|
171
|
-
for (const
|
|
183
|
+
for (const item of inline ? [items[0]] : items) {
|
|
172
184
|
let body, ref, srcHint;
|
|
173
|
-
if (
|
|
185
|
+
if (inline) {
|
|
186
|
+
body = item;
|
|
187
|
+
ref = null;
|
|
188
|
+
srcHint = flags.source ?? "ticket";
|
|
189
|
+
} else if (item === "-") {
|
|
174
190
|
body = fs.readFileSync(0, "utf8"); // stdin
|
|
175
191
|
ref = null;
|
|
176
192
|
srcHint = "founder_note";
|
|
177
193
|
} else {
|
|
178
|
-
if (!fs.existsSync(
|
|
179
|
-
body = fs.readFileSync(
|
|
180
|
-
ref = `upload/${path.basename(
|
|
181
|
-
srcHint = detectSource(
|
|
194
|
+
if (!fs.existsSync(item)) { console.log(` ${C.w}skip${C.r} ${item} (not found)`); failed++; continue; }
|
|
195
|
+
body = fs.readFileSync(item, "utf8");
|
|
196
|
+
ref = `upload/${path.basename(item)}`;
|
|
197
|
+
srcHint = detectSource(item);
|
|
182
198
|
}
|
|
183
|
-
if (!body.trim()) { console.log(` ${C.w}skip${C.r} ${
|
|
199
|
+
if (!body.trim()) { console.log(` ${C.w}skip${C.r} ${item} (empty)`); continue; }
|
|
184
200
|
|
|
185
201
|
const res = await fetch(`${cfg.url}/api/ingest`, {
|
|
186
202
|
method: "POST",
|
|
@@ -188,19 +204,60 @@ async function add(args) {
|
|
|
188
204
|
body: JSON.stringify({
|
|
189
205
|
source: flags.source ?? srcHint,
|
|
190
206
|
body,
|
|
191
|
-
|
|
207
|
+
// omit when absent — the API's schema rejects an explicit null
|
|
208
|
+
externalRef: ref ?? undefined,
|
|
192
209
|
segment: flags.segment,
|
|
193
210
|
}),
|
|
194
211
|
});
|
|
195
212
|
const data = await res.json().catch(() => ({}));
|
|
196
|
-
if (!res.ok) { console.log(` ${C.w}fail${C.r} ${
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
if (!res.ok) { console.log(` ${C.w}fail${C.r} ${inline ? "inline signal" : item} (${res.status})`); failed++; continue; }
|
|
214
|
+
const label = inline ? `"${body.slice(0, 56)}${body.length > 56 ? "…" : ""}"` : item;
|
|
215
|
+
if (data.status === "duplicate") { console.log(` ${C.dim}dup ${C.r} ${label}`); dup++; }
|
|
216
|
+
else { console.log(` ${C.g}✓${C.r} ${label} ${C.dim}→ ${flags.source ?? srcHint} · sig_${(data.signalId ?? "").slice(0, 8)}${C.r}`); created++; }
|
|
217
|
+
if (inline) inlineSignalId = data.signalId ?? null;
|
|
218
|
+
|
|
219
|
+
// Local receipt so an agent reading the brain sees what signal exists.
|
|
220
|
+
if (cfg.inited && data.signalId) {
|
|
221
|
+
writeSignalReceipt(cfg.dir, {
|
|
222
|
+
id: data.signalId,
|
|
223
|
+
source: flags.source ?? srcHint,
|
|
224
|
+
externalRef: ref,
|
|
225
|
+
segment: flags.segment ?? null,
|
|
226
|
+
preview: body.slice(0, 240),
|
|
227
|
+
status: data.status === "duplicate" ? "duplicate" : "queued_for_embedding",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
199
230
|
}
|
|
200
231
|
|
|
232
|
+
if (cfg.inited) appendLog(cfg.dir, "add", `${created} added, ${dup} dup, ${failed} failed${inline ? " (inline)" : ""}`);
|
|
201
233
|
console.log(`\n ${created} added${dup ? `, ${dup} already there` : ""}${failed ? `, ${failed} failed` : ""}.`);
|
|
234
|
+
|
|
235
|
+
// Inline signal = an issue. Analyze it against the brain now (the existing
|
|
236
|
+
// embedded signal + decisions) and materialize context.md.
|
|
237
|
+
if (inline && (created > 0 || dup > 0)) {
|
|
238
|
+
console.log(` ${C.dim}Analyzing against the brain — retrieved signal · prior decisions… (~10s)${C.r}`);
|
|
239
|
+
try {
|
|
240
|
+
const qres = await fetch(`${cfg.url}/api/query`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
|
|
243
|
+
body: JSON.stringify({ query: items[0], window: "all" }),
|
|
244
|
+
});
|
|
245
|
+
const q = await qres.json();
|
|
246
|
+
if (qres.ok) {
|
|
247
|
+
const md = renderContextLive(items[0], q, cfg.project, inlineSignalId);
|
|
248
|
+
if (cfg.inited) fs.writeFileSync(path.join(cfg.dir, "context.md"), md);
|
|
249
|
+
const unresolved = (q.contradictions ?? []).length;
|
|
250
|
+
console.log(`\n ${C.g}✓${C.r} context synthesized${cfg.inited ? ` → ${C.dim}${path.join(cfg.dir, "context.md")}${C.r}` : ""}`);
|
|
251
|
+
console.log(` ${C.dim}${(q.sources ?? []).length} signal(s) cited${unresolved ? ` · ${C.r}${C.w}⚠ ${unresolved} contradiction(s) with prior decisions — gate is UNRESOLVED${C.r}` : ""}${C.r}`);
|
|
252
|
+
if (cfg.inited) appendLog(cfg.dir, "add", `context.md synthesized (${(q.sources ?? []).length} cited, ${unresolved} contradictions)`);
|
|
253
|
+
}
|
|
254
|
+
} catch { /* analysis is best-effort; the signal is already ingested */ }
|
|
255
|
+
console.log(`\n ${C.b}Next:${C.r} ${C.g}scriptonia plan "${items[0].slice(0, 48)}${items[0].length > 48 ? "…" : ""}"${C.r}\n`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
202
259
|
if (created > 0) {
|
|
203
|
-
console.log(` ${C.dim}The brain is enriching them now.
|
|
260
|
+
console.log(` ${C.dim}The brain is enriching them now. Then:${C.r} ${C.g}scriptonia plan "<issue>"${C.r}\n`);
|
|
204
261
|
}
|
|
205
262
|
}
|
|
206
263
|
|
|
@@ -288,8 +345,19 @@ async function comment(args) {
|
|
|
288
345
|
body: JSON.stringify({ slug, body: text, author: flags.as ?? cfg.email }),
|
|
289
346
|
});
|
|
290
347
|
const data = await res.json().catch(() => ({}));
|
|
348
|
+
if (res.status === 402) failCredits(data);
|
|
291
349
|
if (res.status === 404) fail(`no plan "${slug}" — generate one first: scriptonia plan "<issue>"`);
|
|
292
350
|
if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data).slice(0, 200)}`);
|
|
351
|
+
// Approval-chain receipt: which human note produced which plan version.
|
|
352
|
+
if (cfg.inited) {
|
|
353
|
+
const cdir = path.join(cfg.dir, "comments", "plan", slug);
|
|
354
|
+
fs.mkdirSync(cdir, { recursive: true });
|
|
355
|
+
fs.writeFileSync(
|
|
356
|
+
path.join(cdir, `v${data.version}.json`),
|
|
357
|
+
JSON.stringify({ author: flags.as ?? cfg.email, body: text, applied_in_version: data.version, at: new Date().toISOString() }, null, 2),
|
|
358
|
+
);
|
|
359
|
+
appendLog(cfg.dir, "comment", `plan/${slug} — regenerated to v${data.version}`);
|
|
360
|
+
}
|
|
293
361
|
writePlan(cfg, data, flags);
|
|
294
362
|
return;
|
|
295
363
|
}
|
|
@@ -301,6 +369,7 @@ async function comment(args) {
|
|
|
301
369
|
body: JSON.stringify({ slug: target, body: text, author: flags.as ?? cfg.email }),
|
|
302
370
|
});
|
|
303
371
|
const data = await res.json().catch(() => ({}));
|
|
372
|
+
if (res.status === 402) failCredits(data);
|
|
304
373
|
if (res.status === 404) fail(`no context "${target}" — see \`scriptonia contexts\``);
|
|
305
374
|
if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data)}`);
|
|
306
375
|
|
|
@@ -323,6 +392,7 @@ async function query(args) {
|
|
|
323
392
|
body: JSON.stringify({ query: q, window: flags.window, segment: flags.segment }),
|
|
324
393
|
});
|
|
325
394
|
const data = await res.json();
|
|
395
|
+
if (res.status === 402) failCredits(data);
|
|
326
396
|
if (!res.ok) fail(`query failed (${res.status}): ${JSON.stringify(data)}`);
|
|
327
397
|
|
|
328
398
|
const wantJson = flags.json !== undefined || !process.stdout.isTTY;
|
|
@@ -351,12 +421,65 @@ async function link(args) {
|
|
|
351
421
|
async function status() {
|
|
352
422
|
const cfg = loadConfig();
|
|
353
423
|
if (!cfg) { console.log("not logged in — run `scriptonia login`"); return; }
|
|
424
|
+
const active = activeCreds();
|
|
425
|
+
const C = colors();
|
|
354
426
|
console.log(`signed in as ${cfg.email}`);
|
|
355
|
-
console.log(`project ${
|
|
427
|
+
console.log(`project ${active.project}`);
|
|
428
|
+
const c = await fetchCredits(active);
|
|
429
|
+
if (c && c.metered !== false) {
|
|
430
|
+
const bar = creditBar(c.used, c.cap, C);
|
|
431
|
+
console.log(`plan ${C.b}${c.plan}${C.r}`);
|
|
432
|
+
console.log(`credits ${bar} ${c.used}/${c.cap} used · resets ${c.resets_at}`);
|
|
433
|
+
if (c.available <= 0) console.log(` ${C.w}out of credits${C.r} — ${c.topup}`);
|
|
434
|
+
else if (c.available <= Math.max(2, Math.round(c.cap * 0.2))) console.log(` ${C.dim}running low — ${c.topup}${C.r}`);
|
|
435
|
+
}
|
|
356
436
|
console.log(`endpoint ${cfg.url}`);
|
|
357
437
|
console.log(`config ${CONFIG_PATH}`);
|
|
358
438
|
}
|
|
359
439
|
|
|
440
|
+
// ── credits ───────────────────────────────────────────────────
|
|
441
|
+
async function credits() {
|
|
442
|
+
const cfg = activeCreds();
|
|
443
|
+
const C = colors();
|
|
444
|
+
const c = await fetchCredits(cfg);
|
|
445
|
+
if (!c) fail("could not load credits");
|
|
446
|
+
if (c.metered === false) { console.log(`\n ${C.dim}This project isn't metered.${C.r}\n`); return; }
|
|
447
|
+
console.log(`\n ${C.b}${c.plan.toUpperCase()}${C.r} · ${cfg.project}`);
|
|
448
|
+
console.log(` ${creditBar(c.used, c.cap, C)} ${C.b}${c.available}${C.r} of ${c.cap} credits left ${C.dim}(resets ${c.resets_at})${C.r}`);
|
|
449
|
+
console.log(`\n ${C.dim}Plans & comments cost 5 · queries 1 · adding signal is free.${C.r}`);
|
|
450
|
+
if (c.available <= 0) console.log(` ${C.w}Out of credits.${C.r} ${c.topup}`);
|
|
451
|
+
else console.log(` ${C.dim}Need more? ${c.topup}${C.r}`);
|
|
452
|
+
console.log("");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function fetchCredits(cfg) {
|
|
456
|
+
try {
|
|
457
|
+
const res = await fetch(`${cfg.url}/api/credits`, { headers: { authorization: `Bearer ${cfg.token}` } });
|
|
458
|
+
if (!res.ok) return null;
|
|
459
|
+
return await res.json();
|
|
460
|
+
} catch { return null; }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function creditBar(used, cap, C) {
|
|
464
|
+
const width = 16;
|
|
465
|
+
const frac = cap > 0 ? Math.min(1, used / cap) : 0;
|
|
466
|
+
const filled = Math.round(frac * width);
|
|
467
|
+
const color = frac >= 1 ? C.w : frac >= 0.8 ? C.w : C.g;
|
|
468
|
+
return `${color}${"█".repeat(filled)}${C.dim}${"░".repeat(width - filled)}${C.r}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Render an HTTP 402 credits_exhausted body and exit. Shared by plan/query/comment. */
|
|
472
|
+
function failCredits(data) {
|
|
473
|
+
const C = colors();
|
|
474
|
+
const resets = data.resets_at ? ` (resets ${data.resets_at})` : "";
|
|
475
|
+
console.error(`\n ${C.w}✕ Out of credits${C.r} — ${data.plan}: ${data.used}/${data.cap} used this month${resets}.\n`);
|
|
476
|
+
console.error(` Your signal is safe and still being collected. Plans & queries resume`);
|
|
477
|
+
console.error(` when credits do.\n`);
|
|
478
|
+
console.error(` ${C.b}Top up:${C.r} ${data.topup}`);
|
|
479
|
+
console.error(` ${C.dim}(adding signal, sync, contexts, plans list — all still free)${C.r}\n`);
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
|
|
360
483
|
async function logout() {
|
|
361
484
|
if (fs.existsSync(CONFIG_PATH)) fs.rmSync(CONFIG_PATH);
|
|
362
485
|
console.log("logged out.");
|
|
@@ -560,8 +683,10 @@ function usage() {
|
|
|
560
683
|
scriptonia query "<topic>" quick lookup, no full plan (--json)
|
|
561
684
|
scriptonia link "<id>" a signal's full text
|
|
562
685
|
scriptonia sync write the brain to local files
|
|
686
|
+
scriptonia credits plan, balance, reset date
|
|
563
687
|
|
|
564
688
|
ACCOUNT scriptonia status | logout | skill | mcp
|
|
689
|
+
Plans & comments cost 5 credits · queries 1 · adding signal is free.
|
|
565
690
|
`);
|
|
566
691
|
}
|
|
567
692
|
|
|
@@ -575,10 +700,14 @@ function activeCreds() {
|
|
|
575
700
|
if (!g) fail("run `scriptonia login` first");
|
|
576
701
|
const proj = findProjectForCwd();
|
|
577
702
|
if (proj) {
|
|
703
|
+
// v0.6 brains are flat (REPO.md etc. at the project dir root); older ones
|
|
704
|
+
// used a brain/ subfolder — keep reading those until re-init.
|
|
705
|
+
const legacy = !fs.existsSync(path.join(proj.dir, "REPO.md")) && fs.existsSync(path.join(proj.dir, "brain"));
|
|
578
706
|
return {
|
|
579
707
|
url: g.url, email: g.email,
|
|
580
708
|
token: proj.data.token, project: proj.data.name, projectId: proj.data.projectId,
|
|
581
|
-
dir: proj.dir, brainDir: path.join(proj.dir, "brain")
|
|
709
|
+
dir: proj.dir, brainDir: legacy ? path.join(proj.dir, "brain") : proj.dir,
|
|
710
|
+
plansDir: path.join(proj.dir, "plans"),
|
|
582
711
|
repoPath: proj.data.repoPath, inited: true,
|
|
583
712
|
};
|
|
584
713
|
}
|
|
@@ -643,8 +772,16 @@ async function init(args) {
|
|
|
643
772
|
|
|
644
773
|
const pitch = flags.about ?? (await ask(` In one or two sentences — what is ${name} and who is it for?\n > `));
|
|
645
774
|
|
|
646
|
-
|
|
647
|
-
|
|
775
|
+
// Brain structure (v0.6, flat): everything an agent needs to be grounded,
|
|
776
|
+
// as plain files. cache/ + embeddings/ intentionally absent — vectors live
|
|
777
|
+
// server-side (pgvector); writing fake index files here would be theater.
|
|
778
|
+
for (const sub of ["signals", "rules", "contexts", "plans", "comments/plan", "logs"]) {
|
|
779
|
+
fs.mkdirSync(path.join(dir, sub), { recursive: true });
|
|
780
|
+
}
|
|
781
|
+
// Migrate a legacy brain/ subfolder layout if present.
|
|
782
|
+
const legacyBrain = path.join(dir, "brain");
|
|
783
|
+
if (fs.existsSync(legacyBrain)) fs.rmSync(legacyBrain, { recursive: true, force: true });
|
|
784
|
+
|
|
648
785
|
fs.writeFileSync(
|
|
649
786
|
path.join(dir, "project.json"),
|
|
650
787
|
JSON.stringify({ projectId, token, name, slug, repoPath, pitch: pitch || null, createdAt: new Date().toISOString() }, null, 2),
|
|
@@ -652,14 +789,18 @@ async function init(args) {
|
|
|
652
789
|
);
|
|
653
790
|
writeProjectMd(dir, scan, pitch);
|
|
654
791
|
writeRepoMd(dir, scan);
|
|
792
|
+
writeAgentsBrainMd(dir, { name, pitch, stack: scan.stack }, []);
|
|
793
|
+
fs.writeFileSync(path.join(dir, ".gitignore"), "logs/\nproject.json\n");
|
|
655
794
|
injectAgentsMd(cwd);
|
|
656
795
|
installSkill();
|
|
796
|
+
appendLog(dir, "init", `Initialized brain for ${name} (${repoPath}) — stack: ${scan.stack}`);
|
|
657
797
|
|
|
658
798
|
console.log(`\n ${C.g}✓${C.r} Brain created for ${C.b}${name}${C.r} ${C.dim}→ ${dir}${C.r}`);
|
|
659
|
-
console.log(`
|
|
799
|
+
console.log(` ${C.dim}AGENTS.md · PROJECT.md · REPO.md · signals/ · rules/ · contexts/ · plans/ · comments/ · logs/${C.r}`);
|
|
800
|
+
console.log(` ${C.g}✓${C.r} Scanned repo (${scan.stack})`);
|
|
660
801
|
console.log(` ${C.g}✓${C.r} Wrote agent instructions ${C.dim}→ ./AGENTS.md${C.r}`);
|
|
661
802
|
console.log(`\n ${C.b}Feed it customer signal, then turn an issue into a plan:${C.r}\n`);
|
|
662
|
-
console.log(` ${C.g}scriptonia add${C.r} feedback.txt call.vtt
|
|
803
|
+
console.log(` ${C.g}scriptonia add${C.r} feedback.txt call.vtt ${C.dim}(or: scriptonia add "Issue #42: …")${C.r}`);
|
|
663
804
|
console.log(` ${C.g}scriptonia plan${C.r} "the issue you want to build"\n`);
|
|
664
805
|
}
|
|
665
806
|
|
|
@@ -676,9 +817,22 @@ async function sync() {
|
|
|
676
817
|
fs.mkdirSync(cdir, { recursive: true });
|
|
677
818
|
for (const f of fs.readdirSync(cdir)) if (f.endsWith(".md")) fs.rmSync(path.join(cdir, f));
|
|
678
819
|
for (const c of brain.contexts) fs.writeFileSync(path.join(cdir, `${c.slug}.md`), renderContextFile(c));
|
|
679
|
-
fs.writeFileSync(path.join(cfg.brainDir, "DECISIONS.md"), renderDecisions(brain.decisions));
|
|
680
820
|
|
|
681
|
-
|
|
821
|
+
// Decisions → machine-checkable rules/. The server enforces these during
|
|
822
|
+
// `plan` (conflicts come back ⚠ UNRESOLVED); the local copies ground agents.
|
|
823
|
+
const rdir = path.join(cfg.brainDir, "rules");
|
|
824
|
+
fs.mkdirSync(rdir, { recursive: true });
|
|
825
|
+
for (const f of fs.readdirSync(rdir)) if (f.endsWith(".rule")) fs.rmSync(path.join(rdir, f));
|
|
826
|
+
for (const d of brain.decisions) {
|
|
827
|
+
fs.writeFileSync(path.join(rdir, `${slugify(d.title)}.rule`), renderRule(d));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Refresh the brain's master contract with the live constraint set.
|
|
831
|
+
const pj = readProjectJson(cfg.dir);
|
|
832
|
+
writeAgentsBrainMd(cfg.brainDir, { name: cfg.project, pitch: pj?.pitch ?? "", stack: null }, brain.decisions);
|
|
833
|
+
|
|
834
|
+
appendLog(cfg.dir, "sync", `${brain.contexts.length} contexts, ${brain.decisions.length} decisions → rules/`);
|
|
835
|
+
console.log(` ${C.g}✓${C.r} synced ${brain.contexts.length} context(s) → contexts/, ${brain.decisions.length} decision(s) → rules/ ${C.dim}(${cfg.brainDir})${C.r}`);
|
|
682
836
|
}
|
|
683
837
|
|
|
684
838
|
// ── plan (the money command) ──────────────────────────────────
|
|
@@ -708,6 +862,7 @@ async function plan(args) {
|
|
|
708
862
|
body: JSON.stringify({ issue, repo_summary: repoSummary }),
|
|
709
863
|
});
|
|
710
864
|
const data = await res.json();
|
|
865
|
+
if (res.status === 402) failCredits(data);
|
|
711
866
|
if (!res.ok) fail(`plan failed (${res.status}): ${JSON.stringify(data).slice(0, 200)}`);
|
|
712
867
|
writePlan(cfg, data, flags);
|
|
713
868
|
}
|
|
@@ -719,11 +874,21 @@ function writePlan(cfg, data, flags) {
|
|
|
719
874
|
if (cfg.inited) {
|
|
720
875
|
fs.mkdirSync(cfg.plansDir, { recursive: true });
|
|
721
876
|
fs.writeFileSync(path.join(cfg.plansDir, `${data.slug}.md`), data.markdown);
|
|
877
|
+
// Live copy in the brain, so an agent grounded on the brain dir sees the
|
|
878
|
+
// current plan without knowing the slug.
|
|
879
|
+
fs.writeFileSync(path.join(cfg.brainDir, "plan.md"), data.markdown);
|
|
880
|
+
appendLog(cfg.dir, "plan", `${data.slug} v${data.version} — ${(data.sources ?? []).length} sources, ${data.contradictions ?? 0} unresolved`);
|
|
722
881
|
}
|
|
723
882
|
console.log(`\n ${C.g}✓${C.r} Plan ${C.b}${data.slug}${C.r} ${C.dim}v${data.version}${C.r} → ${C.v}${out}${C.r}`);
|
|
724
883
|
console.log(` ${C.dim}${(data.sources ?? []).length} cited signal(s)${data.contradictions ? `, ${data.contradictions} unresolved contradiction(s)` : ""}${C.r}`);
|
|
725
884
|
console.log(`\n ${C.b}Hand it to your agent:${C.r} ${C.g}claude "execute PLAN.md"${C.r} ${C.dim}(or codex · opencode · hermes)${C.r}`);
|
|
726
|
-
console.log(` ${C.dim}refine:${C.r} ${C.g}scriptonia comment plan/${data.slug} "…"${C.r} ${C.dim}(rewrites PLAN.md)${C.r}
|
|
885
|
+
console.log(` ${C.dim}refine:${C.r} ${C.g}scriptonia comment plan/${data.slug} "…"${C.r} ${C.dim}(rewrites PLAN.md)${C.r}`);
|
|
886
|
+
if (data.credits) {
|
|
887
|
+
const cr = data.credits;
|
|
888
|
+
const low = cr.cap > 0 && cr.cap - cr.used <= Math.max(2, Math.round(cr.cap * 0.2));
|
|
889
|
+
console.log(` ${low ? C.w : C.dim}${cr.used}/${cr.cap} credits used this month${low ? " — top up: sathwik07@scriptonia.dev" : ""}${C.r}`);
|
|
890
|
+
}
|
|
891
|
+
console.log("");
|
|
727
892
|
}
|
|
728
893
|
|
|
729
894
|
// ── repo scan ─────────────────────────────────────────────────
|
|
@@ -799,26 +964,170 @@ function gitRemoteName(cwd) {
|
|
|
799
964
|
// ── brain file writers ────────────────────────────────────────
|
|
800
965
|
function writeProjectMd(dir, scan, pitch) {
|
|
801
966
|
const md = [
|
|
967
|
+
"---",
|
|
968
|
+
`project_name: ${JSON.stringify(scan.name)}`,
|
|
969
|
+
`stack: ${JSON.stringify(scan.stack)}`,
|
|
970
|
+
scan.scripts.length ? `scripts: [${scan.scripts.join(", ")}]` : null,
|
|
971
|
+
`initialized: ${new Date().toISOString().slice(0, 10)}`,
|
|
972
|
+
"---",
|
|
973
|
+
"",
|
|
802
974
|
`# ${scan.name}`,
|
|
803
975
|
"",
|
|
804
976
|
pitch ? `> ${pitch}` : (scan.pkgDescription ? `> ${scan.pkgDescription}` : "> (describe this product with `scriptonia init --force`)"),
|
|
805
977
|
"",
|
|
806
978
|
`**Stack:** ${scan.stack}`,
|
|
807
|
-
scan.scripts.length ? `**Scripts:** ${scan.scripts.join(", ")}` : "",
|
|
808
979
|
scan.deps.length ? `**Key deps:** ${scan.deps.join(", ")}` : "",
|
|
809
980
|
scan.entryPoints.length ? `**Entry points:** ${scan.entryPoints.join(", ")}` : "",
|
|
810
981
|
"",
|
|
811
982
|
scan.readmeHead ? "## From the README\n\n" + scan.readmeHead : "",
|
|
812
983
|
"",
|
|
813
984
|
"---",
|
|
814
|
-
"*
|
|
815
|
-
].filter((l) => l !== "").join("\n");
|
|
816
|
-
fs.writeFileSync(path.join(dir, "
|
|
985
|
+
"*The product brain. Signal receipts in `signals/`, constraints in `rules/`, enriched contexts in `contexts/`, live synthesis in `context.md`, live plan in `plan.md`. Generated by `scriptonia init`.*",
|
|
986
|
+
].filter((l) => l !== "" && l !== null).join("\n");
|
|
987
|
+
fs.writeFileSync(path.join(dir, "PROJECT.md"), md);
|
|
817
988
|
}
|
|
818
989
|
|
|
819
990
|
function writeRepoMd(dir, scan) {
|
|
820
|
-
|
|
821
|
-
fs.
|
|
991
|
+
// v0.6 flat layout; keep writing into brain/ for pre-0.6 projects.
|
|
992
|
+
const target = fs.existsSync(path.join(dir, "brain")) && !fs.existsSync(path.join(dir, "REPO.md"))
|
|
993
|
+
? path.join(dir, "brain")
|
|
994
|
+
: dir;
|
|
995
|
+
fs.mkdirSync(target, { recursive: true });
|
|
996
|
+
fs.writeFileSync(path.join(target, "REPO.md"), buildRepoMd(scan));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* AGENTS.md — the brain's master contract. What an agent must respect when
|
|
1001
|
+
* acting on this project: identity, the Scriptonia workflow, and the
|
|
1002
|
+
* non-negotiable constraints (mirrored from the decisions corpus; the server
|
|
1003
|
+
* enforces them during `plan`, this copy grounds agents working offline).
|
|
1004
|
+
*/
|
|
1005
|
+
function writeAgentsBrainMd(dir, meta, decisions) {
|
|
1006
|
+
const L = [
|
|
1007
|
+
`# AGENTS.md — ${meta.name} project brain`,
|
|
1008
|
+
`# Generated by: scriptonia (refreshed on \`sync\`)`,
|
|
1009
|
+
"",
|
|
1010
|
+
"## 1. Identity",
|
|
1011
|
+
"",
|
|
1012
|
+
meta.pitch ? `${meta.pitch}` : "(run `scriptonia init --force` to describe this product)",
|
|
1013
|
+
meta.stack ? `\nStack: ${meta.stack}` : "",
|
|
1014
|
+
"",
|
|
1015
|
+
"## 2. Workflow — non-negotiable",
|
|
1016
|
+
"",
|
|
1017
|
+
'1. Before building any user-facing change, run `scriptonia plan "<the issue>"`.',
|
|
1018
|
+
" It writes PLAN.md, grounded in real customer signal and checked against",
|
|
1019
|
+
" the rules below.",
|
|
1020
|
+
"2. Execute PLAN.md **exactly**. Its **Non-goals** section is a fence — do not",
|
|
1021
|
+
" build past it.",
|
|
1022
|
+
"3. Any constraint marked **⚠ UNRESOLVED** is an approval gate: STOP and get a",
|
|
1023
|
+
' human comment (`scriptonia comment plan/<slug> "…"`) before writing code.',
|
|
1024
|
+
"4. Cite PLAN.md's `sources` signal ids in your PR description.",
|
|
1025
|
+
"",
|
|
1026
|
+
"## 3. Non-negotiable constraints (from the decisions corpus)",
|
|
1027
|
+
"",
|
|
1028
|
+
];
|
|
1029
|
+
if (decisions.length === 0) {
|
|
1030
|
+
L.push("_None synced yet — run `scriptonia sync` after decisions exist. The server_", "_still enforces them during `plan` regardless._");
|
|
1031
|
+
} else {
|
|
1032
|
+
for (const d of decisions) {
|
|
1033
|
+
L.push(`### ${d.title}${d.ref ? ` (${d.ref})` : ""}`, "", d.body, "");
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
L.push(
|
|
1037
|
+
"",
|
|
1038
|
+
"## 4. Approval gates",
|
|
1039
|
+
"",
|
|
1040
|
+
"- Overriding any constraint above requires an explicit human comment on the",
|
|
1041
|
+
" plan. The comment is recorded in `comments/plan/<slug>/` and flips the",
|
|
1042
|
+
" plan's gate to RESOLVED on regeneration.",
|
|
1043
|
+
"",
|
|
1044
|
+
);
|
|
1045
|
+
fs.writeFileSync(path.join(dir, "AGENTS.md"), L.join("\n"));
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** Decision → machine-checkable rule file. */
|
|
1049
|
+
function renderRule(d) {
|
|
1050
|
+
const id = slugify(d.title);
|
|
1051
|
+
return [
|
|
1052
|
+
"---",
|
|
1053
|
+
`rule_id: ${JSON.stringify(id)}`,
|
|
1054
|
+
`name: ${JSON.stringify(d.title)}`,
|
|
1055
|
+
"severity: BLOCKING",
|
|
1056
|
+
d.ref ? `source: ${JSON.stringify(d.ref)}` : null,
|
|
1057
|
+
d.decidedAt ? `decided_at: ${JSON.stringify(String(d.decidedAt).slice(0, 10))}` : null,
|
|
1058
|
+
"check: |",
|
|
1059
|
+
...String(d.body).split("\n").map((l) => ` ${l}`),
|
|
1060
|
+
"enforcement: |",
|
|
1061
|
+
" Server-side during `scriptonia plan`: any issue conflicting with this rule",
|
|
1062
|
+
" is marked ⚠ UNRESOLVED in PLAN.md until a human comment approves the",
|
|
1063
|
+
" override (recorded in comments/plan/<slug>/).",
|
|
1064
|
+
"---",
|
|
1065
|
+
].filter(Boolean).join("\n") + "\n";
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** Per-signal local receipt: signals/sig_<8>.json */
|
|
1069
|
+
function writeSignalReceipt(dir, sig) {
|
|
1070
|
+
const sdir = path.join(dir, "signals");
|
|
1071
|
+
fs.mkdirSync(sdir, { recursive: true });
|
|
1072
|
+
fs.writeFileSync(
|
|
1073
|
+
path.join(sdir, `sig_${sig.id.slice(0, 8)}.json`),
|
|
1074
|
+
JSON.stringify({ ...sig, added_at: new Date().toISOString() }, null, 2),
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** context.md — live synthesis of the current issue against the brain. */
|
|
1079
|
+
function renderContextLive(issue, q, project, signalId) {
|
|
1080
|
+
const cite = (ids) => (ids?.length ? ` \`[${ids.map((i) => i.slice(0, 8)).join(", ")}]\`` : "");
|
|
1081
|
+
const L = [
|
|
1082
|
+
`# Context — ${issue.slice(0, 90)}${issue.length > 90 ? "…" : ""}`,
|
|
1083
|
+
"",
|
|
1084
|
+
`> Synthesized ${new Date().toISOString().slice(0, 10)} against the **${project}** brain · ${(q.sources ?? []).length} signal(s) retrieved${signalId ? ` · this issue ingested as \`sig_${signalId.slice(0, 8)}\`` : ""}`,
|
|
1085
|
+
"",
|
|
1086
|
+
"## TL;DR",
|
|
1087
|
+
q.tldr ?? "(no synthesis)",
|
|
1088
|
+
"",
|
|
1089
|
+
"## What customers actually asked for",
|
|
1090
|
+
];
|
|
1091
|
+
for (const r of q.requirements ?? []) {
|
|
1092
|
+
L.push(`- **${r.title}** _[${r.strength}]_ — ${r.detail}${cite(r.source_ids)}`);
|
|
1093
|
+
}
|
|
1094
|
+
if (!(q.requirements ?? []).length) L.push("- (no supporting signal retrieved — treat scope with caution)");
|
|
1095
|
+
L.push("");
|
|
1096
|
+
|
|
1097
|
+
const cons = q.contradictions ?? [];
|
|
1098
|
+
if (cons.length) {
|
|
1099
|
+
L.push("## ⚠ CONTRADICTION GATE — UNRESOLVED");
|
|
1100
|
+
for (const c of cons) L.push(`- ${c.detail} (${c.decision_ref})`);
|
|
1101
|
+
L.push("", "**STATUS: ⚠ UNRESOLVED.** A human must approve the override before any plan", "built on this context is executed. `scriptonia plan` will carry this gate.", "");
|
|
1102
|
+
} else {
|
|
1103
|
+
L.push("## Contradiction gate", "", "✓ No conflicts with prior decisions on record.", "");
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if ((q.edge_cases ?? []).length) {
|
|
1107
|
+
L.push("## Edge cases — fold into any plan");
|
|
1108
|
+
for (const e of q.edge_cases) L.push(`- ${e}`);
|
|
1109
|
+
L.push("");
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
L.push("## Cited signals");
|
|
1113
|
+
for (const s of q.sources ?? []) {
|
|
1114
|
+
L.push(`- \`sig_${s.signal_id.slice(0, 8)}\` ${s.source}${s.ref ? ` — ${s.ref}` : ""} _(similarity ${s.similarity})_`);
|
|
1115
|
+
}
|
|
1116
|
+
L.push("", "---", `*Next: \`scriptonia plan "${issue.slice(0, 60)}${issue.length > 60 ? "…" : ""}"\` → PLAN.md.*`);
|
|
1117
|
+
return L.join("\n");
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function readProjectJson(dir) {
|
|
1121
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, "project.json"), "utf8")); } catch { return null; }
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Append to the brain's audit trail: logs/<name>.log */
|
|
1125
|
+
function appendLog(dir, name, msg) {
|
|
1126
|
+
try {
|
|
1127
|
+
const ldir = path.join(dir, "logs");
|
|
1128
|
+
fs.mkdirSync(ldir, { recursive: true });
|
|
1129
|
+
fs.appendFileSync(path.join(ldir, `${name}.log`), `[${new Date().toISOString()}] INFO scriptonia.${name} — ${msg}\n`);
|
|
1130
|
+
} catch { /* audit trail is best-effort */ }
|
|
822
1131
|
}
|
|
823
1132
|
|
|
824
1133
|
function buildRepoMd(scan) {
|
|
@@ -844,13 +1153,6 @@ function renderContextFile(c) {
|
|
|
844
1153
|
return L.join("\n");
|
|
845
1154
|
}
|
|
846
1155
|
|
|
847
|
-
function renderDecisions(decs) {
|
|
848
|
-
if (!decs?.length) return "# Decisions\n\n(none on record)\n";
|
|
849
|
-
const L = ["# Decisions", "", "*The contradiction corpus — plans are checked against these.*", ""];
|
|
850
|
-
for (const d of decs) L.push(`## ${d.title}${d.ref ? ` (${d.ref})` : ""}`, "", d.body, "");
|
|
851
|
-
return L.join("\n");
|
|
852
|
-
}
|
|
853
|
-
|
|
854
1156
|
// ── AGENTS.md injection (every agent reads this) ──────────────
|
|
855
1157
|
function injectAgentsMd(cwd) {
|
|
856
1158
|
const file = path.join(cwd, "AGENTS.md");
|