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.
Files changed (2) hide show
  1. package/bin/scriptonia.mjs +342 -40
  2. package/package.json +1 -1
@@ -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
- console.log(` ${C.dim}THE LOOP${C.r} ${C.dim}${s.total} signals · ${plansN} plans${C.r}`);
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)}\n`);
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 files = args.filter((a) => !a.startsWith("--"));
159
- if (files.length === 0) {
164
+ const items = args.filter((a) => !a.startsWith("--"));
165
+ if (items.length === 0) {
160
166
  fail(
161
- 'usage: scriptonia add <file...> e.g. scriptonia add slack.txt notes.md call.vtt\n' +
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 file of files) {
183
+ for (const item of inline ? [items[0]] : items) {
172
184
  let body, ref, srcHint;
173
- if (file === "-") {
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(file)) { console.log(` ${C.w}skip${C.r} ${file} (not found)`); failed++; continue; }
179
- body = fs.readFileSync(file, "utf8");
180
- ref = `upload/${path.basename(file)}`;
181
- srcHint = detectSource(file);
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} ${file} (empty)`); continue; }
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
- externalRef: ref,
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} ${file} (${res.status})`); failed++; continue; }
197
- if (data.status === "duplicate") { console.log(` ${C.dim}dup ${C.r} ${file}`); dup++; }
198
- else { console.log(` ${C.g}✓${C.r} ${file} ${C.dim} ${flags.source ?? srcHint}${C.r}`); created++; }
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. In ~2 min:${C.r} ${C.g}scriptonia contexts${C.r}\n`);
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 ${cfg.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"), plansDir: path.join(proj.dir, "plans"),
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
- fs.mkdirSync(path.join(dir, "brain", "contexts"), { recursive: true });
647
- fs.mkdirSync(path.join(dir, "plans"), { recursive: true });
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(` ${C.g}✓${C.r} Scanned repo (${scan.stack}) ${C.dim}→ brain/PROJECT.md · brain/REPO.md${C.r}`);
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 notes.md`);
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
- console.log(` ${C.g}✓${C.r} synced ${brain.contexts.length} context(s), ${brain.decisions.length} decision(s) ${C.dim}${cfg.brainDir}${C.r}`);
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}\n`);
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
- "*This is the product brain. Customer signal, contexts, and decisions live alongside this file. Generated by `scriptonia init`.*",
815
- ].filter((l) => l !== "").join("\n");
816
- fs.writeFileSync(path.join(dir, "brain", "PROJECT.md"), md);
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
- fs.mkdirSync(path.join(dir, "brain"), { recursive: true });
821
- fs.writeFileSync(path.join(dir, "brain", "REPO.md"), buildRepoMd(scan));
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scriptonia",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Turn customer signal into executable plans your coding agent runs. Issue in, PLAN.md out.",
5
5
  "type": "module",
6
6
  "bin": {