scriptonia 0.5.0 → 0.6.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 +266 -35
  2. package/package.json +1 -1
@@ -155,10 +155,11 @@ function nextStep(s, plansN, recentPlan, repo, C) {
155
155
  async function add(args) {
156
156
  const cfg = activeCreds();
157
157
  const flags = parseFlags(args);
158
- const files = args.filter((a) => !a.startsWith("--"));
159
- if (files.length === 0) {
158
+ const items = args.filter((a) => !a.startsWith("--"));
159
+ if (items.length === 0) {
160
160
  fail(
161
- 'usage: scriptonia add <file...> e.g. scriptonia add slack.txt notes.md call.vtt\n' +
161
+ 'usage: scriptonia add <file...> e.g. scriptonia add slack.txt notes.md call.vtt\n' +
162
+ ' scriptonia add "Issue #42: …" inline signal — also analyzed against the brain → context.md\n' +
162
163
  " flags: --source slack|ticket|sales_call|voice_note|founder_note|email|notion|github\n" +
163
164
  " --segment enterprise|mid_market|smb|free\n" +
164
165
  " pipe: cat thread.txt | scriptonia add -",
@@ -166,21 +167,30 @@ async function add(args) {
166
167
  }
167
168
 
168
169
  const C = colors();
170
+ // A single non-file argument with whitespace is inline signal text (an issue,
171
+ // a pasted message). A bare word that isn't a file stays a "not found" error
172
+ // so typo'd filenames don't get silently ingested as one-word signals.
173
+ const inline = items.length === 1 && items[0] !== "-" && !fs.existsSync(items[0]) && /\s/.test(items[0]);
169
174
  let created = 0, dup = 0, failed = 0;
175
+ let inlineSignalId = null;
170
176
 
171
- for (const file of files) {
177
+ for (const item of inline ? [items[0]] : items) {
172
178
  let body, ref, srcHint;
173
- if (file === "-") {
179
+ if (inline) {
180
+ body = item;
181
+ ref = null;
182
+ srcHint = flags.source ?? "ticket";
183
+ } else if (item === "-") {
174
184
  body = fs.readFileSync(0, "utf8"); // stdin
175
185
  ref = null;
176
186
  srcHint = "founder_note";
177
187
  } 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);
188
+ if (!fs.existsSync(item)) { console.log(` ${C.w}skip${C.r} ${item} (not found)`); failed++; continue; }
189
+ body = fs.readFileSync(item, "utf8");
190
+ ref = `upload/${path.basename(item)}`;
191
+ srcHint = detectSource(item);
182
192
  }
183
- if (!body.trim()) { console.log(` ${C.w}skip${C.r} ${file} (empty)`); continue; }
193
+ if (!body.trim()) { console.log(` ${C.w}skip${C.r} ${item} (empty)`); continue; }
184
194
 
185
195
  const res = await fetch(`${cfg.url}/api/ingest`, {
186
196
  method: "POST",
@@ -188,19 +198,60 @@ async function add(args) {
188
198
  body: JSON.stringify({
189
199
  source: flags.source ?? srcHint,
190
200
  body,
191
- externalRef: ref,
201
+ // omit when absent — the API's schema rejects an explicit null
202
+ externalRef: ref ?? undefined,
192
203
  segment: flags.segment,
193
204
  }),
194
205
  });
195
206
  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++; }
207
+ if (!res.ok) { console.log(` ${C.w}fail${C.r} ${inline ? "inline signal" : item} (${res.status})`); failed++; continue; }
208
+ const label = inline ? `"${body.slice(0, 56)}${body.length > 56 ? "…" : ""}"` : item;
209
+ if (data.status === "duplicate") { console.log(` ${C.dim}dup ${C.r} ${label}`); dup++; }
210
+ else { console.log(` ${C.g}✓${C.r} ${label} ${C.dim}→ ${flags.source ?? srcHint} · sig_${(data.signalId ?? "").slice(0, 8)}${C.r}`); created++; }
211
+ if (inline) inlineSignalId = data.signalId ?? null;
212
+
213
+ // Local receipt so an agent reading the brain sees what signal exists.
214
+ if (cfg.inited && data.signalId) {
215
+ writeSignalReceipt(cfg.dir, {
216
+ id: data.signalId,
217
+ source: flags.source ?? srcHint,
218
+ externalRef: ref,
219
+ segment: flags.segment ?? null,
220
+ preview: body.slice(0, 240),
221
+ status: data.status === "duplicate" ? "duplicate" : "queued_for_embedding",
222
+ });
223
+ }
199
224
  }
200
225
 
226
+ if (cfg.inited) appendLog(cfg.dir, "add", `${created} added, ${dup} dup, ${failed} failed${inline ? " (inline)" : ""}`);
201
227
  console.log(`\n ${created} added${dup ? `, ${dup} already there` : ""}${failed ? `, ${failed} failed` : ""}.`);
228
+
229
+ // Inline signal = an issue. Analyze it against the brain now (the existing
230
+ // embedded signal + decisions) and materialize context.md.
231
+ if (inline && (created > 0 || dup > 0)) {
232
+ console.log(` ${C.dim}Analyzing against the brain — retrieved signal · prior decisions… (~10s)${C.r}`);
233
+ try {
234
+ const qres = await fetch(`${cfg.url}/api/query`, {
235
+ method: "POST",
236
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
237
+ body: JSON.stringify({ query: items[0], window: "all" }),
238
+ });
239
+ const q = await qres.json();
240
+ if (qres.ok) {
241
+ const md = renderContextLive(items[0], q, cfg.project, inlineSignalId);
242
+ if (cfg.inited) fs.writeFileSync(path.join(cfg.dir, "context.md"), md);
243
+ const unresolved = (q.contradictions ?? []).length;
244
+ console.log(`\n ${C.g}✓${C.r} context synthesized${cfg.inited ? ` → ${C.dim}${path.join(cfg.dir, "context.md")}${C.r}` : ""}`);
245
+ 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}`);
246
+ if (cfg.inited) appendLog(cfg.dir, "add", `context.md synthesized (${(q.sources ?? []).length} cited, ${unresolved} contradictions)`);
247
+ }
248
+ } catch { /* analysis is best-effort; the signal is already ingested */ }
249
+ console.log(`\n ${C.b}Next:${C.r} ${C.g}scriptonia plan "${items[0].slice(0, 48)}${items[0].length > 48 ? "…" : ""}"${C.r}\n`);
250
+ return;
251
+ }
252
+
202
253
  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`);
254
+ console.log(` ${C.dim}The brain is enriching them now. Then:${C.r} ${C.g}scriptonia plan "<issue>"${C.r}\n`);
204
255
  }
205
256
  }
206
257
 
@@ -290,6 +341,16 @@ async function comment(args) {
290
341
  const data = await res.json().catch(() => ({}));
291
342
  if (res.status === 404) fail(`no plan "${slug}" — generate one first: scriptonia plan "<issue>"`);
292
343
  if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data).slice(0, 200)}`);
344
+ // Approval-chain receipt: which human note produced which plan version.
345
+ if (cfg.inited) {
346
+ const cdir = path.join(cfg.dir, "comments", "plan", slug);
347
+ fs.mkdirSync(cdir, { recursive: true });
348
+ fs.writeFileSync(
349
+ path.join(cdir, `v${data.version}.json`),
350
+ JSON.stringify({ author: flags.as ?? cfg.email, body: text, applied_in_version: data.version, at: new Date().toISOString() }, null, 2),
351
+ );
352
+ appendLog(cfg.dir, "comment", `plan/${slug} — regenerated to v${data.version}`);
353
+ }
293
354
  writePlan(cfg, data, flags);
294
355
  return;
295
356
  }
@@ -575,10 +636,14 @@ function activeCreds() {
575
636
  if (!g) fail("run `scriptonia login` first");
576
637
  const proj = findProjectForCwd();
577
638
  if (proj) {
639
+ // v0.6 brains are flat (REPO.md etc. at the project dir root); older ones
640
+ // used a brain/ subfolder — keep reading those until re-init.
641
+ const legacy = !fs.existsSync(path.join(proj.dir, "REPO.md")) && fs.existsSync(path.join(proj.dir, "brain"));
578
642
  return {
579
643
  url: g.url, email: g.email,
580
644
  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"),
645
+ dir: proj.dir, brainDir: legacy ? path.join(proj.dir, "brain") : proj.dir,
646
+ plansDir: path.join(proj.dir, "plans"),
582
647
  repoPath: proj.data.repoPath, inited: true,
583
648
  };
584
649
  }
@@ -643,8 +708,16 @@ async function init(args) {
643
708
 
644
709
  const pitch = flags.about ?? (await ask(` In one or two sentences — what is ${name} and who is it for?\n > `));
645
710
 
646
- fs.mkdirSync(path.join(dir, "brain", "contexts"), { recursive: true });
647
- fs.mkdirSync(path.join(dir, "plans"), { recursive: true });
711
+ // Brain structure (v0.6, flat): everything an agent needs to be grounded,
712
+ // as plain files. cache/ + embeddings/ intentionally absent — vectors live
713
+ // server-side (pgvector); writing fake index files here would be theater.
714
+ for (const sub of ["signals", "rules", "contexts", "plans", "comments/plan", "logs"]) {
715
+ fs.mkdirSync(path.join(dir, sub), { recursive: true });
716
+ }
717
+ // Migrate a legacy brain/ subfolder layout if present.
718
+ const legacyBrain = path.join(dir, "brain");
719
+ if (fs.existsSync(legacyBrain)) fs.rmSync(legacyBrain, { recursive: true, force: true });
720
+
648
721
  fs.writeFileSync(
649
722
  path.join(dir, "project.json"),
650
723
  JSON.stringify({ projectId, token, name, slug, repoPath, pitch: pitch || null, createdAt: new Date().toISOString() }, null, 2),
@@ -652,14 +725,18 @@ async function init(args) {
652
725
  );
653
726
  writeProjectMd(dir, scan, pitch);
654
727
  writeRepoMd(dir, scan);
728
+ writeAgentsBrainMd(dir, { name, pitch, stack: scan.stack }, []);
729
+ fs.writeFileSync(path.join(dir, ".gitignore"), "logs/\nproject.json\n");
655
730
  injectAgentsMd(cwd);
656
731
  installSkill();
732
+ appendLog(dir, "init", `Initialized brain for ${name} (${repoPath}) — stack: ${scan.stack}`);
657
733
 
658
734
  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}`);
735
+ console.log(` ${C.dim}AGENTS.md · PROJECT.md · REPO.md · signals/ · rules/ · contexts/ · plans/ · comments/ · logs/${C.r}`);
736
+ console.log(` ${C.g}✓${C.r} Scanned repo (${scan.stack})`);
660
737
  console.log(` ${C.g}✓${C.r} Wrote agent instructions ${C.dim}→ ./AGENTS.md${C.r}`);
661
738
  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`);
739
+ console.log(` ${C.g}scriptonia add${C.r} feedback.txt call.vtt ${C.dim}(or: scriptonia add "Issue #42: …")${C.r}`);
663
740
  console.log(` ${C.g}scriptonia plan${C.r} "the issue you want to build"\n`);
664
741
  }
665
742
 
@@ -676,9 +753,22 @@ async function sync() {
676
753
  fs.mkdirSync(cdir, { recursive: true });
677
754
  for (const f of fs.readdirSync(cdir)) if (f.endsWith(".md")) fs.rmSync(path.join(cdir, f));
678
755
  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
756
 
681
- console.log(` ${C.g}✓${C.r} synced ${brain.contexts.length} context(s), ${brain.decisions.length} decision(s) ${C.dim}${cfg.brainDir}${C.r}`);
757
+ // Decisions machine-checkable rules/. The server enforces these during
758
+ // `plan` (conflicts come back ⚠ UNRESOLVED); the local copies ground agents.
759
+ const rdir = path.join(cfg.brainDir, "rules");
760
+ fs.mkdirSync(rdir, { recursive: true });
761
+ for (const f of fs.readdirSync(rdir)) if (f.endsWith(".rule")) fs.rmSync(path.join(rdir, f));
762
+ for (const d of brain.decisions) {
763
+ fs.writeFileSync(path.join(rdir, `${slugify(d.title)}.rule`), renderRule(d));
764
+ }
765
+
766
+ // Refresh the brain's master contract with the live constraint set.
767
+ const pj = readProjectJson(cfg.dir);
768
+ writeAgentsBrainMd(cfg.brainDir, { name: cfg.project, pitch: pj?.pitch ?? "", stack: null }, brain.decisions);
769
+
770
+ appendLog(cfg.dir, "sync", `${brain.contexts.length} contexts, ${brain.decisions.length} decisions → rules/`);
771
+ 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
772
  }
683
773
 
684
774
  // ── plan (the money command) ──────────────────────────────────
@@ -719,6 +809,10 @@ function writePlan(cfg, data, flags) {
719
809
  if (cfg.inited) {
720
810
  fs.mkdirSync(cfg.plansDir, { recursive: true });
721
811
  fs.writeFileSync(path.join(cfg.plansDir, `${data.slug}.md`), data.markdown);
812
+ // Live copy in the brain, so an agent grounded on the brain dir sees the
813
+ // current plan without knowing the slug.
814
+ fs.writeFileSync(path.join(cfg.brainDir, "plan.md"), data.markdown);
815
+ appendLog(cfg.dir, "plan", `${data.slug} v${data.version} — ${(data.sources ?? []).length} sources, ${data.contradictions ?? 0} unresolved`);
722
816
  }
723
817
  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
818
  console.log(` ${C.dim}${(data.sources ?? []).length} cited signal(s)${data.contradictions ? `, ${data.contradictions} unresolved contradiction(s)` : ""}${C.r}`);
@@ -799,26 +893,170 @@ function gitRemoteName(cwd) {
799
893
  // ── brain file writers ────────────────────────────────────────
800
894
  function writeProjectMd(dir, scan, pitch) {
801
895
  const md = [
896
+ "---",
897
+ `project_name: ${JSON.stringify(scan.name)}`,
898
+ `stack: ${JSON.stringify(scan.stack)}`,
899
+ scan.scripts.length ? `scripts: [${scan.scripts.join(", ")}]` : null,
900
+ `initialized: ${new Date().toISOString().slice(0, 10)}`,
901
+ "---",
902
+ "",
802
903
  `# ${scan.name}`,
803
904
  "",
804
905
  pitch ? `> ${pitch}` : (scan.pkgDescription ? `> ${scan.pkgDescription}` : "> (describe this product with `scriptonia init --force`)"),
805
906
  "",
806
907
  `**Stack:** ${scan.stack}`,
807
- scan.scripts.length ? `**Scripts:** ${scan.scripts.join(", ")}` : "",
808
908
  scan.deps.length ? `**Key deps:** ${scan.deps.join(", ")}` : "",
809
909
  scan.entryPoints.length ? `**Entry points:** ${scan.entryPoints.join(", ")}` : "",
810
910
  "",
811
911
  scan.readmeHead ? "## From the README\n\n" + scan.readmeHead : "",
812
912
  "",
813
913
  "---",
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);
914
+ "*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`.*",
915
+ ].filter((l) => l !== "" && l !== null).join("\n");
916
+ fs.writeFileSync(path.join(dir, "PROJECT.md"), md);
817
917
  }
818
918
 
819
919
  function writeRepoMd(dir, scan) {
820
- fs.mkdirSync(path.join(dir, "brain"), { recursive: true });
821
- fs.writeFileSync(path.join(dir, "brain", "REPO.md"), buildRepoMd(scan));
920
+ // v0.6 flat layout; keep writing into brain/ for pre-0.6 projects.
921
+ const target = fs.existsSync(path.join(dir, "brain")) && !fs.existsSync(path.join(dir, "REPO.md"))
922
+ ? path.join(dir, "brain")
923
+ : dir;
924
+ fs.mkdirSync(target, { recursive: true });
925
+ fs.writeFileSync(path.join(target, "REPO.md"), buildRepoMd(scan));
926
+ }
927
+
928
+ /**
929
+ * AGENTS.md — the brain's master contract. What an agent must respect when
930
+ * acting on this project: identity, the Scriptonia workflow, and the
931
+ * non-negotiable constraints (mirrored from the decisions corpus; the server
932
+ * enforces them during `plan`, this copy grounds agents working offline).
933
+ */
934
+ function writeAgentsBrainMd(dir, meta, decisions) {
935
+ const L = [
936
+ `# AGENTS.md — ${meta.name} project brain`,
937
+ `# Generated by: scriptonia (refreshed on \`sync\`)`,
938
+ "",
939
+ "## 1. Identity",
940
+ "",
941
+ meta.pitch ? `${meta.pitch}` : "(run `scriptonia init --force` to describe this product)",
942
+ meta.stack ? `\nStack: ${meta.stack}` : "",
943
+ "",
944
+ "## 2. Workflow — non-negotiable",
945
+ "",
946
+ '1. Before building any user-facing change, run `scriptonia plan "<the issue>"`.',
947
+ " It writes PLAN.md, grounded in real customer signal and checked against",
948
+ " the rules below.",
949
+ "2. Execute PLAN.md **exactly**. Its **Non-goals** section is a fence — do not",
950
+ " build past it.",
951
+ "3. Any constraint marked **⚠ UNRESOLVED** is an approval gate: STOP and get a",
952
+ ' human comment (`scriptonia comment plan/<slug> "…"`) before writing code.',
953
+ "4. Cite PLAN.md's `sources` signal ids in your PR description.",
954
+ "",
955
+ "## 3. Non-negotiable constraints (from the decisions corpus)",
956
+ "",
957
+ ];
958
+ if (decisions.length === 0) {
959
+ L.push("_None synced yet — run `scriptonia sync` after decisions exist. The server_", "_still enforces them during `plan` regardless._");
960
+ } else {
961
+ for (const d of decisions) {
962
+ L.push(`### ${d.title}${d.ref ? ` (${d.ref})` : ""}`, "", d.body, "");
963
+ }
964
+ }
965
+ L.push(
966
+ "",
967
+ "## 4. Approval gates",
968
+ "",
969
+ "- Overriding any constraint above requires an explicit human comment on the",
970
+ " plan. The comment is recorded in `comments/plan/<slug>/` and flips the",
971
+ " plan's gate to RESOLVED on regeneration.",
972
+ "",
973
+ );
974
+ fs.writeFileSync(path.join(dir, "AGENTS.md"), L.join("\n"));
975
+ }
976
+
977
+ /** Decision → machine-checkable rule file. */
978
+ function renderRule(d) {
979
+ const id = slugify(d.title);
980
+ return [
981
+ "---",
982
+ `rule_id: ${JSON.stringify(id)}`,
983
+ `name: ${JSON.stringify(d.title)}`,
984
+ "severity: BLOCKING",
985
+ d.ref ? `source: ${JSON.stringify(d.ref)}` : null,
986
+ d.decidedAt ? `decided_at: ${JSON.stringify(String(d.decidedAt).slice(0, 10))}` : null,
987
+ "check: |",
988
+ ...String(d.body).split("\n").map((l) => ` ${l}`),
989
+ "enforcement: |",
990
+ " Server-side during `scriptonia plan`: any issue conflicting with this rule",
991
+ " is marked ⚠ UNRESOLVED in PLAN.md until a human comment approves the",
992
+ " override (recorded in comments/plan/<slug>/).",
993
+ "---",
994
+ ].filter(Boolean).join("\n") + "\n";
995
+ }
996
+
997
+ /** Per-signal local receipt: signals/sig_<8>.json */
998
+ function writeSignalReceipt(dir, sig) {
999
+ const sdir = path.join(dir, "signals");
1000
+ fs.mkdirSync(sdir, { recursive: true });
1001
+ fs.writeFileSync(
1002
+ path.join(sdir, `sig_${sig.id.slice(0, 8)}.json`),
1003
+ JSON.stringify({ ...sig, added_at: new Date().toISOString() }, null, 2),
1004
+ );
1005
+ }
1006
+
1007
+ /** context.md — live synthesis of the current issue against the brain. */
1008
+ function renderContextLive(issue, q, project, signalId) {
1009
+ const cite = (ids) => (ids?.length ? ` \`[${ids.map((i) => i.slice(0, 8)).join(", ")}]\`` : "");
1010
+ const L = [
1011
+ `# Context — ${issue.slice(0, 90)}${issue.length > 90 ? "…" : ""}`,
1012
+ "",
1013
+ `> 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)}\`` : ""}`,
1014
+ "",
1015
+ "## TL;DR",
1016
+ q.tldr ?? "(no synthesis)",
1017
+ "",
1018
+ "## What customers actually asked for",
1019
+ ];
1020
+ for (const r of q.requirements ?? []) {
1021
+ L.push(`- **${r.title}** _[${r.strength}]_ — ${r.detail}${cite(r.source_ids)}`);
1022
+ }
1023
+ if (!(q.requirements ?? []).length) L.push("- (no supporting signal retrieved — treat scope with caution)");
1024
+ L.push("");
1025
+
1026
+ const cons = q.contradictions ?? [];
1027
+ if (cons.length) {
1028
+ L.push("## ⚠ CONTRADICTION GATE — UNRESOLVED");
1029
+ for (const c of cons) L.push(`- ${c.detail} (${c.decision_ref})`);
1030
+ 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.", "");
1031
+ } else {
1032
+ L.push("## Contradiction gate", "", "✓ No conflicts with prior decisions on record.", "");
1033
+ }
1034
+
1035
+ if ((q.edge_cases ?? []).length) {
1036
+ L.push("## Edge cases — fold into any plan");
1037
+ for (const e of q.edge_cases) L.push(`- ${e}`);
1038
+ L.push("");
1039
+ }
1040
+
1041
+ L.push("## Cited signals");
1042
+ for (const s of q.sources ?? []) {
1043
+ L.push(`- \`sig_${s.signal_id.slice(0, 8)}\` ${s.source}${s.ref ? ` — ${s.ref}` : ""} _(similarity ${s.similarity})_`);
1044
+ }
1045
+ L.push("", "---", `*Next: \`scriptonia plan "${issue.slice(0, 60)}${issue.length > 60 ? "…" : ""}"\` → PLAN.md.*`);
1046
+ return L.join("\n");
1047
+ }
1048
+
1049
+ function readProjectJson(dir) {
1050
+ try { return JSON.parse(fs.readFileSync(path.join(dir, "project.json"), "utf8")); } catch { return null; }
1051
+ }
1052
+
1053
+ /** Append to the brain's audit trail: logs/<name>.log */
1054
+ function appendLog(dir, name, msg) {
1055
+ try {
1056
+ const ldir = path.join(dir, "logs");
1057
+ fs.mkdirSync(ldir, { recursive: true });
1058
+ fs.appendFileSync(path.join(ldir, `${name}.log`), `[${new Date().toISOString()}] INFO scriptonia.${name} — ${msg}\n`);
1059
+ } catch { /* audit trail is best-effort */ }
822
1060
  }
823
1061
 
824
1062
  function buildRepoMd(scan) {
@@ -844,13 +1082,6 @@ function renderContextFile(c) {
844
1082
  return L.join("\n");
845
1083
  }
846
1084
 
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
1085
  // ── AGENTS.md injection (every agent reads this) ──────────────
855
1086
  function injectAgentsMd(cwd) {
856
1087
  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.6.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": {