scriptonia 0.2.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.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # scriptonia
2
2
 
3
- Product memory for your coding agent. One command, browser sign-in, and your
4
- agent reads what your customers actually asked for with sources it can cite.
3
+ Turn customer signal into **executable plans your coding agent runs**. An issue
4
+ goes in; a hyper-focused, sourced, contradiction-checked `PLAN.md` comes out
5
+ your agent (Claude Code, Codex, OpenClaw, Hermes) executes it into a PR.
5
6
 
6
7
  ## Quick start
7
8
 
@@ -9,46 +10,65 @@ agent reads what your customers actually asked for — with sources it can cite.
9
10
  npx scriptonia login
10
11
  ```
11
12
 
12
- That's it. A browser opens, you sign in with Google, and:
13
+ A browser opens, you sign in, and you're wired: project created, key stored (you
14
+ never see one), and your agent's `AGENTS.md` / skill now know about `scriptonia
15
+ plan`. Then, in your project:
13
16
 
14
- - your project is created (with a sample dataset so you can try it immediately),
15
- - an API key is minted and stored for you (you never see or paste a key),
16
- - the Claude Code skill is installed to `~/.claude/skills/scriptonia/`.
17
+ ```sh
18
+ scriptonia add feedback.txt call.vtt notes.md # feed customer signal
19
+ scriptonia plan "let compliance filter the audit log by actor"
20
+ # → writes PLAN.md: goal, cited evidence, non-goals, real file paths, acceptance criteria
21
+ claude "execute PLAN.md" # your agent ships it → PR
22
+ ```
17
23
 
18
- Then your agent or you — can ask the product brain:
24
+ Left a review note? The plan regenerates:
19
25
 
20
26
  ```sh
21
- scriptonia query "audit log filtering"
27
+ scriptonia comment plan/<slug> "default to all actors, and gate behind the admin role"
28
+ # → PLAN.md v2 with your note folded in
22
29
  ```
23
30
 
31
+ ## The loop
32
+
33
+ | step | command | what happens |
34
+ |---|---|---|
35
+ | **SIGNAL** | `scriptonia add <files>` | Ingest text · md · vtt as customer signal (deduped, auto-sourced). |
36
+ | **PLAN** | `scriptonia plan "<issue>"` | Retrieve the relevant signal, check prior decisions, and write **PLAN.md** — every claim cites a signal id; contradictions are gated **UNRESOLVED** until a human approves. |
37
+ | **BUILD** | `claude "execute PLAN.md"` | Your agent runs the plan verbatim. Respects the Non-goals fence; cites sources in the PR. |
38
+ | **REFINE** | `scriptonia comment plan/<slug> "…"` | A note regenerates the plan (v+1). A comment approving an override flips a contradiction to **RESOLVED**. |
39
+
24
40
  ## Commands
25
41
 
26
- | command | what it does |
27
- |---|---|
28
- | `scriptonia login` | Browser sign-in. Stores creds in `~/.scriptonia/config.json`, installs the skill. |
29
- | `scriptonia query "<topic>"` | Ask the product brain. Flags: `--window 7d\|30d\|90d\|all`, `--segment enterprise\|mid_market\|smb\|free`, `--json`. |
30
- | `scriptonia link "<signal_id>"` | Resolve a signal id (from `query` sources) to its full text. |
31
- | `scriptonia status` | Who you are / which project. |
32
- | `scriptonia logout` | Forget credentials. |
33
- | `scriptonia mcp` | Run as an MCP server over stdio (for MCP-native agents like Codex). |
42
+ ```
43
+ setup login init [per repo]
44
+ signal add <files> (--source, --segment, or cat x | scriptonia add -)
45
+ plan plan "<issue>" (--out <file>, --refresh <slug>)
46
+ plans list plans + their slugs
47
+ comment plan/<slug> "…" refine regenerates PLAN.md
48
+ inspect scriptonia the loop + what's next
49
+ contexts | query "<topic>" | link "<id>" | sync
50
+ account status | logout | skill | mcp
51
+ ```
34
52
 
35
- ## For MCP-native agents
53
+ ## One brain per repo (for people juggling projects)
54
+
55
+ `scriptonia init` in a repo gives it its own brain at
56
+ `~/.scriptonia/projects/<slug>/`, scans the code into a repo map (so plan steps
57
+ name real files), and writes an `AGENTS.md` section. Confidential signal stays
58
+ in your home dir — only `PLAN.md` and the `AGENTS.md` note land in the repo.
36
59
 
37
- Point your agent at:
60
+ Skip `init` if you have one product — `login → add → plan` uses your default
61
+ brain and scans whatever repo you run `plan` in.
62
+
63
+ ## For MCP-native agents
38
64
 
39
65
  ```json
40
- {
41
- "command": "npx",
42
- "args": ["-y", "scriptonia", "mcp"]
43
- }
66
+ { "command": "npx", "args": ["-y", "scriptonia", "mcp"] }
44
67
  ```
45
68
 
46
- It exposes `get_context`, `link_back`, and `flag_contradiction`, reading your
47
- stored credentials — no env vars needed after `scriptonia login`.
69
+ Exposes `get_context`, `link_back`, `flag_contradiction` from your stored login.
48
70
 
49
71
  ## Self-hosting
50
72
 
51
- By default the CLI talks to `https://scriptonia.dev`. Point it elsewhere with
52
- `SCRIPTONIA_URL=https://your.host scriptonia login` (or `--url`).
53
-
54
- Requires Node 18+.
73
+ Defaults to `https://scriptonia.dev`. Point elsewhere with
74
+ `SCRIPTONIA_URL=https://your.host scriptonia login` (or `--url`). Node 18+.
@@ -1,34 +1,43 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * scriptonia — product memory for your coding agent.
3
+ * scriptonia — issue executable plan for your coding agent.
4
4
  *
5
- * scriptonia login browser sign-in; stores creds; installs the skill
6
- * scriptonia query "<topic>" ask the product brain (what the agent calls)
7
- * scriptonia link "<signal_id>" resolve a signal to its full text
8
- * scriptonia status who am I / which project
9
- * scriptonia logout forget creds
10
- * scriptonia mcp speak MCP over stdio (for MCP-native agents)
5
+ * scriptonia login browser sign-in; wires your agent
6
+ * scriptonia add <files> feed customer signal (text · md · vtt)
7
+ * scriptonia plan "<issue>" PLAN.md: sourced, contradiction-checked
8
+ * scriptonia comment plan/<slug> refine regenerates PLAN.md
9
+ * scriptonia init [per repo] give a repo its own brain
10
+ * scriptonia mcp MCP over stdio (for MCP-native agents)
11
11
  *
12
- * After `login`, everything lives in ~/.scriptonia/config.json. No env vars,
13
- * no key pasting. The installed Claude Code skill just runs `scriptonia query`.
14
- *
15
- * Pure ESM, zero build step — runs on Node 18+.
12
+ * After `login`, everything lives in ~/.scriptonia/. No env vars, no key
13
+ * pasting. Pure ESM, zero build step runs on Node 18+.
16
14
  */
17
15
  import fs from "node:fs";
18
16
  import http from "node:http";
19
17
  import os from "node:os";
20
18
  import path from "node:path";
21
- import { spawn } from "node:child_process";
19
+ import readline from "node:readline";
20
+ import { spawn, execSync } from "node:child_process";
22
21
  import { randomBytes } from "node:crypto";
23
22
 
24
23
  const DEFAULT_URL = process.env.SCRIPTONIA_URL || "https://scriptonia.dev";
25
24
  const CONFIG_DIR = path.join(os.homedir(), ".scriptonia");
26
25
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
26
+ const PROJECTS_DIR = path.join(CONFIG_DIR, "projects");
27
27
 
28
28
  const [, , cmd, ...rest] = process.argv;
29
29
 
30
- const commands = { login, query, link, status, logout, init: reinstallSkill, mcp, help: async () => usage() };
31
- const run = commands[cmd ?? "help"] ?? (async () => { usage(); process.exit(1); });
30
+ const commands = {
31
+ login, init, sync,
32
+ add, ingest: add, push: add,
33
+ plan, plans, comment,
34
+ query, contexts, link,
35
+ status, logout, skill: reinstallSkill, mcp,
36
+ flow, guide: flow,
37
+ help: async () => usage(),
38
+ };
39
+ // No command → show the guided flow (where am I / what's next), not raw usage.
40
+ const run = commands[cmd ?? "flow"] ?? (async () => { usage(); process.exit(1); });
32
41
  run(rest).catch((e) => {
33
42
  console.error("error:", e instanceof Error ? e.message : String(e));
34
43
  process.exit(1);
@@ -79,18 +88,292 @@ async function login(args) {
79
88
  saveConfig(result);
80
89
  installSkill();
81
90
 
82
- console.log(`\n ✓ Signed in as ${result.email}`);
83
- console.log(` Project "${result.project}" ready (with a sample dataset to try)`);
84
- console.log(` Skill installed ~/.claude/skills/scriptonia/SKILL.md`);
85
- console.log(`\n Your agent now reads your customers. Try it:\n`);
86
- console.log(` scriptonia query "audit log filtering"\n`);
91
+ const C = colors();
92
+ console.log(`\n ${C.g}✓${C.r} Signed in as ${result.email} ${C.dim}(project "${result.project}", with sample data to try)${C.r}`);
93
+ console.log(` ${C.g}✓${C.r} Agent wired skill + AGENTS.md know about \`scriptonia plan\``);
94
+ console.log(`\n ${C.b}The loop feed signal, turn an issue into a plan your agent runs:${C.r}\n`);
95
+ console.log(` ${C.g}scriptonia add${C.r} feedback.txt call.vtt ${C.dim}feed customer signal${C.r}`);
96
+ console.log(` ${C.g}scriptonia plan${C.r} "the thing to build" ${C.dim}→ PLAN.md (sourced, checked)${C.r}`);
97
+ console.log(` ${C.g}claude${C.r} "execute PLAN.md" ${C.dim}your agent ships it${C.r}`);
98
+ console.log(`\n ${C.dim}Try it on the sample data now:${C.r} ${C.g}scriptonia plan "filter audit log by actor"${C.r}`);
99
+ console.log(` ${C.dim}Working across repos? Run${C.r} ${C.g}scriptonia init${C.r} ${C.dim}in each for its own brain.${C.r}\n`);
100
+ }
101
+
102
+ // ─────────────────────────────────────────────────────────────
103
+ // flow — the guided map: where am I, what's next (bare `scriptonia`)
104
+ // ─────────────────────────────────────────────────────────────
105
+ async function flow() {
106
+ const cfg = loadConfig();
107
+ const C = colors();
108
+ if (!cfg) {
109
+ console.log(`\n ${C.b}SCRIPTONIA${C.r} · turn customer signal into plans your agent can execute\n`);
110
+ console.log(` One command sets everything up:\n`);
111
+ console.log(` ${C.g}scriptonia login${C.r}\n`);
112
+ return;
113
+ }
114
+
115
+ const repo = findProjectForCwd();
116
+ const token = repo ? repo.data.token : cfg.token;
117
+ const projName = repo ? repo.data.name : cfg.project;
118
+
119
+ let o = null;
120
+ try {
121
+ const res = await fetch(`${cfg.url}/api/overview`, { headers: { authorization: `Bearer ${token}` } });
122
+ if (res.ok) o = await res.json();
123
+ } catch { /* offline */ }
124
+
125
+ const s = o?.signals ?? { total: 0, enriched: 0 };
126
+ const plansN = o?.plans_count ?? 0;
127
+ const recentPlan = o?.plans?.[0]?.slug ?? null;
128
+
129
+ console.log(`\n ${C.b}SCRIPTONIA${C.r} · issue → executable plan for your agent`);
130
+ console.log(` ${C.dim}${cfg.email} · project ${o?.project ?? projName}${repo ? "" : C.dim + " (global — run `scriptonia init` in a repo for its own brain)"}${C.r}\n`);
131
+
132
+ const row = (n, name, desc, cmd) =>
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
+
135
+ console.log(` ${C.dim}THE LOOP${C.r} ${C.dim}${s.total} signals · ${plansN} plans${C.r}`);
136
+ row("1", "SIGNAL", "feed customer reality (text·md·vtt)", `scriptonia add <files>`);
137
+ row("2", "PLAN", "issue → PLAN.md, sourced & checked", `scriptonia plan "<issue>"`);
138
+ row("3", "BUILD", "your agent executes PLAN.md → PR", `claude "execute PLAN.md"`);
139
+ row("4", "REFINE", "a note regenerates the plan", `scriptonia comment plan/<slug> "…"`);
140
+
141
+ console.log(`\n ${C.b}NEXT${C.r} ${nextStep(s, plansN, recentPlan, repo, C)}\n`);
142
+ }
143
+
144
+ function nextStep(s, plansN, recentPlan, repo, C) {
145
+ if (s.total === 0)
146
+ return `${C.g}scriptonia add feedback.txt call.vtt${C.r} ${C.dim}— feed the brain customer signal${C.r}`;
147
+ if (plansN === 0)
148
+ return `${C.g}scriptonia plan "the thing you're about to build"${C.r} ${C.dim}— writes PLAN.md${C.r}`;
149
+ return `${C.g}claude "execute PLAN.md"${C.r} ${C.dim}— ship it · or refine: ${C.r}${C.g}scriptonia comment plan/${recentPlan} "…"${C.r}`;
150
+ }
151
+
152
+ // ─────────────────────────────────────────────────────────────
153
+ // add — ingest files from the terminal (the founder's on-ramp)
154
+ // ─────────────────────────────────────────────────────────────
155
+ async function add(args) {
156
+ const cfg = activeCreds();
157
+ const flags = parseFlags(args);
158
+ const items = args.filter((a) => !a.startsWith("--"));
159
+ if (items.length === 0) {
160
+ fail(
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' +
163
+ " flags: --source slack|ticket|sales_call|voice_note|founder_note|email|notion|github\n" +
164
+ " --segment enterprise|mid_market|smb|free\n" +
165
+ " pipe: cat thread.txt | scriptonia add -",
166
+ );
167
+ }
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]);
174
+ let created = 0, dup = 0, failed = 0;
175
+ let inlineSignalId = null;
176
+
177
+ for (const item of inline ? [items[0]] : items) {
178
+ let body, ref, srcHint;
179
+ if (inline) {
180
+ body = item;
181
+ ref = null;
182
+ srcHint = flags.source ?? "ticket";
183
+ } else if (item === "-") {
184
+ body = fs.readFileSync(0, "utf8"); // stdin
185
+ ref = null;
186
+ srcHint = "founder_note";
187
+ } else {
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);
192
+ }
193
+ if (!body.trim()) { console.log(` ${C.w}skip${C.r} ${item} (empty)`); continue; }
194
+
195
+ const res = await fetch(`${cfg.url}/api/ingest`, {
196
+ method: "POST",
197
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
198
+ body: JSON.stringify({
199
+ source: flags.source ?? srcHint,
200
+ body,
201
+ // omit when absent — the API's schema rejects an explicit null
202
+ externalRef: ref ?? undefined,
203
+ segment: flags.segment,
204
+ }),
205
+ });
206
+ const data = await res.json().catch(() => ({}));
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
+ }
224
+ }
225
+
226
+ if (cfg.inited) appendLog(cfg.dir, "add", `${created} added, ${dup} dup, ${failed} failed${inline ? " (inline)" : ""}`);
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
+
253
+ if (created > 0) {
254
+ console.log(` ${C.dim}The brain is enriching them now. Then:${C.r} ${C.g}scriptonia plan "<issue>"${C.r}\n`);
255
+ }
256
+ }
257
+
258
+ function detectSource(file) {
259
+ const ext = path.extname(file).toLowerCase();
260
+ if (ext === ".vtt" || ext === ".srt") return "sales_call";
261
+ if (ext === ".md") return "founder_note";
262
+ return "founder_note";
263
+ }
264
+
265
+ // ─────────────────────────────────────────────────────────────
266
+ // contexts — what the brain produced
267
+ // ─────────────────────────────────────────────────────────────
268
+ async function contexts() {
269
+ const cfg = activeCreds();
270
+ const C = colors();
271
+ const res = await fetch(`${cfg.url}/api/overview`, { headers: { authorization: `Bearer ${cfg.token}` } });
272
+ if (!res.ok) fail(`could not load contexts (${res.status})`);
273
+ const o = await res.json();
274
+
275
+ if (o.contexts_count === 0) {
276
+ console.log(`\n ${C.dim}No contexts yet.${C.r}`);
277
+ if ((o.signals?.total ?? 0) === 0) console.log(` Add signal first: ${C.g}scriptonia add <files>${C.r}\n`);
278
+ else console.log(` The brain is still indexing ${o.signals.total} signals — check back in a minute.\n`);
279
+ return;
280
+ }
281
+
282
+ console.log(`\n ${C.b}${o.contexts_count} context${o.contexts_count > 1 ? "s" : ""}${C.r} ${C.dim}· ${o.project}${C.r}\n`);
283
+ for (const c of o.contexts) {
284
+ const warn = c.contradicts ? ` ${C.w}⚠ contradicts${C.r}` : "";
285
+ console.log(` ${C.g}${String(c.strength).padStart(4)}${C.r} ${C.b}${c.title}${C.r} ${C.dim}${c.type} · v${c.version} · ${c.segment}${C.r}${warn}`);
286
+ console.log(` ${C.v}${cfg.url}/contexts/${c.slug}${C.r}`);
287
+ }
288
+ console.log(`\n ${C.dim}Turn any of these into a plan:${C.r} ${C.g}scriptonia plan "<the issue>"${C.r}\n`);
289
+ }
290
+
291
+ // ─────────────────────────────────────────────────────────────
292
+ // plans — list generated plans (find a slug to refine or re-fetch)
293
+ // ─────────────────────────────────────────────────────────────
294
+ async function plans() {
295
+ const cfg = activeCreds();
296
+ const C = colors();
297
+ const res = await fetch(`${cfg.url}/api/overview`, { headers: { authorization: `Bearer ${cfg.token}` } });
298
+ if (!res.ok) fail(`could not load plans (${res.status})`);
299
+ const o = await res.json();
300
+ const list = o.plans ?? [];
301
+ if (list.length === 0) {
302
+ console.log(`\n ${C.dim}No plans yet.${C.r} Generate one: ${C.g}scriptonia plan "<issue>"${C.r}\n`);
303
+ return;
304
+ }
305
+ console.log(`\n ${C.b}${list.length} plan${list.length > 1 ? "s" : ""}${C.r} ${C.dim}· ${o.project}${C.r}\n`);
306
+ for (const p of list) {
307
+ console.log(` ${C.b}${p.slug}${C.r} ${C.dim}v${p.version}${C.r}`);
308
+ console.log(` ${C.dim}${p.issue}${C.r}`);
309
+ console.log(` ${C.g}scriptonia plan --refresh ${p.slug}${C.r} ${C.dim}·${C.r} ${C.g}scriptonia comment plan/${p.slug} "…"${C.r}`);
310
+ }
311
+ console.log("");
312
+ }
313
+
314
+ // ─────────────────────────────────────────────────────────────
315
+ // comment — leave an inline note; the brain absorbs it (version bump)
316
+ // ─────────────────────────────────────────────────────────────
317
+ async function comment(args) {
318
+ const cfg = activeCreds();
319
+ const C = colors();
320
+ const flags = parseFlags(args);
321
+ const positional = args.filter((a) => !a.startsWith("--"));
322
+ const target = positional[0];
323
+ const text = positional.slice(1).join(" ").trim();
324
+ if (!target || !text) {
325
+ fail(
326
+ 'usage: scriptonia comment <slug> "<your note>" comment on a context\n' +
327
+ ' scriptonia comment plan/<slug> "<your note>" comment on a plan → regenerates PLAN.md\n' +
328
+ ' --as "Name" attribute the comment (defaults to you)',
329
+ );
330
+ }
331
+
332
+ // Comment on a PLAN → regenerate it (v+1) and rewrite PLAN.md.
333
+ if (target.startsWith("plan/")) {
334
+ const slug = target.slice(5);
335
+ console.log(`\n ${C.dim}Folding your note into "${slug}" and regenerating…${C.r}`);
336
+ const res = await fetch(`${cfg.url}/api/plan/comment`, {
337
+ method: "POST",
338
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
339
+ body: JSON.stringify({ slug, body: text, author: flags.as ?? cfg.email }),
340
+ });
341
+ const data = await res.json().catch(() => ({}));
342
+ if (res.status === 404) fail(`no plan "${slug}" — generate one first: scriptonia plan "<issue>"`);
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
+ }
354
+ writePlan(cfg, data, flags);
355
+ return;
356
+ }
357
+
358
+ // Comment on a CONTEXT → constrained-patch absorption.
359
+ const res = await fetch(`${cfg.url}/api/comment`, {
360
+ method: "POST",
361
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
362
+ body: JSON.stringify({ slug: target, body: text, author: flags.as ?? cfg.email }),
363
+ });
364
+ const data = await res.json().catch(() => ({}));
365
+ if (res.status === 404) fail(`no context "${target}" — see \`scriptonia contexts\``);
366
+ if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data)}`);
367
+
368
+ console.log(`\n ${C.g}✓${C.r} comment posted on ${C.b}${target}${C.r} ${C.dim}(v${data.from_version})${C.r}`);
369
+ console.log(` ${C.dim}the brain is absorbing it — it'll bump the version. check:${C.r} ${C.g}scriptonia contexts${C.r}\n`);
87
370
  }
88
371
 
89
372
  // ─────────────────────────────────────────────────────────────
90
373
  // query — what the agent (and you) call
91
374
  // ─────────────────────────────────────────────────────────────
92
375
  async function query(args) {
93
- const cfg = requireConfig();
376
+ const cfg = activeCreds();
94
377
  const flags = parseFlags(args);
95
378
  const q = args.filter((a) => !a.startsWith("--")).join(" ").trim();
96
379
  if (!q) fail('usage: scriptonia query "<topic>" [--window 30d] [--segment enterprise] [--json]');
@@ -112,7 +395,7 @@ async function query(args) {
112
395
  // link — resolve a signal_id to its full body
113
396
  // ─────────────────────────────────────────────────────────────
114
397
  async function link(args) {
115
- const cfg = requireConfig();
398
+ const cfg = activeCreds();
116
399
  const id = args.find((a) => !a.startsWith("--"));
117
400
  if (!id) fail('usage: scriptonia link "<signal_id>"');
118
401
  const res = await fetch(`${cfg.url}/api/link_back?signal_id=${encodeURIComponent(id)}`, {
@@ -159,33 +442,31 @@ function skillMarkdown() {
159
442
  return `---
160
443
  name: scriptonia
161
444
  description: >
162
- BEFORE planning any user-facing change — a feature, a bug fix, a refactor with
163
- UX impact, or a non-trivial default — run \`scriptonia query "<topic>"\`. It
164
- returns what THIS project's customers actually asked for (with source ids you
165
- can cite), what prior decisions your plan might contradict, and known edge
166
- cases. Skipping it means shipping a plan that isn't grounded in customer signal.
445
+ BEFORE building any user-facing change — a feature, a bug fix, a refactor with
446
+ UX impact — run \`scriptonia plan "<the issue>"\`. It writes PLAN.md here: a
447
+ hyper-focused, sourced, executable plan grounded in this project's real
448
+ customer signal, prior decisions, and human comments. Execute PLAN.md exactly.
167
449
  ---
168
450
 
169
- # Scriptonia — Product Memory
451
+ # Scriptonia — Product Memory → executable plans
170
452
 
171
- Before writing a plan for anything user-facing, call:
453
+ When asked to build/fix anything user-facing:
172
454
 
173
- \`\`\`sh
174
- scriptonia query "<the topic you're about to build>"
175
- \`\`\`
455
+ 1. Run \`scriptonia plan "<the issue>"\` — it writes **PLAN.md** in the repo.
456
+ 2. Execute PLAN.md **exactly**. Respect its **Non-goals** — do not add anything
457
+ outside the acceptance criteria.
458
+ 3. If a constraint is marked **UNRESOLVED**, STOP and surface it to the human.
459
+ 4. Your PR description must cite the \`sources\` signal ids from PLAN.md.
176
460
 
177
- Optional flags: \`--window 7d|30d|90d|all\` (default 30d), \`--segment enterprise|mid_market|smb|free\`.
461
+ If a plan step conflicts with what you find in the code, run
462
+ \`scriptonia comment plan/<slug> "<what you found>"\` and re-read the regenerated PLAN.md.
178
463
 
179
- Output (JSON when piped) contains:
180
- - **requirements[]** — each with \`source_ids\` and a \`context_url\`. Cite the
181
- source_ids next to the matching step in your plan.
182
- - **contradictions[]** — if non-empty, STOP and surface it to the human before
183
- writing code. Each has a \`decision_ref\`.
184
- - **edge_cases[]** — fold these into the plan.
185
- - **sources[]** — quote a customer verbatim with \`scriptonia link "<signal_id>"\`.
464
+ To just look something up without a full plan: \`scriptonia query "<topic>"\`
465
+ (JSON: requirements with source_ids, contradictions, edge_cases). Quote a
466
+ customer verbatim with \`scriptonia link "<signal_id>"\`.
186
467
 
187
- Rule: every plan step for a user-facing change should cite at least one
188
- source_id. If it can't, you're inventing demand — query first.
468
+ Rule: never invent demand. If nothing in the plan/query supports a step, it
469
+ doesn't belong in the PR.
189
470
  `;
190
471
  }
191
472
 
@@ -193,7 +474,7 @@ source_id. If it can't, you're inventing demand — query first.
193
474
  // mcp — MCP stdio server (for MCP-native agents)
194
475
  // ─────────────────────────────────────────────────────────────
195
476
  async function mcp() {
196
- const cfg = requireConfig();
477
+ const cfg = activeCreds();
197
478
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
198
479
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
199
480
  const { z } = await import("zod");
@@ -275,8 +556,17 @@ function openBrowser(url) {
275
556
  try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); } catch { /* click the printed URL */ }
276
557
  }
277
558
 
559
+ function colors() {
560
+ const on = !!process.stdout.isTTY;
561
+ const w = (code) => (on ? code : "");
562
+ return {
563
+ dim: w("\x1b[2m"), b: w("\x1b[1m"), v: w("\x1b[35m"),
564
+ w: w("\x1b[33m"), g: w("\x1b[32m"), r: w("\x1b[0m"),
565
+ };
566
+ }
567
+
278
568
  function printPretty(d) {
279
- const c = { dim: "\x1b[2m", b: "\x1b[1m", v: "\x1b[35m", w: "\x1b[33m", g: "\x1b[32m", r: "\x1b[0m" };
569
+ const c = colors();
280
570
  console.log(`\n${c.b}${d.tldr ?? ""}${c.r}\n`);
281
571
  for (const req of d.requirements ?? []) {
282
572
  console.log(` ${c.g}●${c.r} ${c.b}${req.title}${c.r} ${c.dim}[${req.strength}]${c.r}`);
@@ -307,16 +597,541 @@ function escapeHtml(s) {
307
597
  }
308
598
  function fail(msg) { console.error(msg); process.exit(1); }
309
599
  function usage() {
310
- console.log(`scriptonia — product memory for your coding agent
600
+ console.log(`scriptonia — issue executable plan for your coding agent
601
+
602
+ THE LOOP
603
+ 1 SIGNAL scriptonia add <files> feed customer reality (text · md · vtt)
604
+ 2 PLAN scriptonia plan "<issue>" → PLAN.md: sourced, contradiction-checked
605
+ 3 BUILD claude "execute PLAN.md" your agent ships it → PR
606
+ 4 REFINE scriptonia comment plan/<slug> "…" a note regenerates the plan
311
607
 
312
- scriptonia login sign in (browser) — installs the skill, no keys to copy
313
- scriptonia query "<topic>" ask the product brain
314
- scriptonia link "<id>" resolve a signal to its full text
315
- scriptonia status show who you are
316
- scriptonia logout forget credentials
317
- scriptonia mcp run as an MCP server (stdio)
608
+ SETUP
609
+ scriptonia login sign in (browser) wires your agent, no keys
610
+ scriptonia init [per repo] give this repo its own brain + AGENTS.md
318
611
 
319
- login flags: --dev --email you@co.com (local testing; skips Google)
320
- query flags: --window 30d --segment enterprise --json
612
+ SIGNAL & PLANS
613
+ scriptonia add <file...> ingest files (--source, --segment; or cat x | scriptonia add -)
614
+ scriptonia plan "<issue>" generate PLAN.md (--out <file>, --refresh <slug>)
615
+ scriptonia plans list your plans (and their slugs)
616
+ scriptonia comment plan/<slug> "…" refine a plan → regenerates PLAN.md
617
+
618
+ INSPECT
619
+ scriptonia the loop + what's next
620
+ scriptonia contexts what the brain learned from your signal
621
+ scriptonia query "<topic>" quick lookup, no full plan (--json)
622
+ scriptonia link "<id>" a signal's full text
623
+ scriptonia sync write the brain to local files
624
+
625
+ ACCOUNT scriptonia status | logout | skill | mcp
321
626
  `);
322
627
  }
628
+
629
+ // ═════════════════════════════════════════════════════════════
630
+ // v2 — per-repo brain: init · sync · plan, pwd resolution
631
+ // ═════════════════════════════════════════════════════════════
632
+
633
+ /** Repo-local project when pwd is inside an initialized repo, else the global login. */
634
+ function activeCreds() {
635
+ const g = loadConfig();
636
+ if (!g) fail("run `scriptonia login` first");
637
+ const proj = findProjectForCwd();
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"));
642
+ return {
643
+ url: g.url, email: g.email,
644
+ token: proj.data.token, project: proj.data.name, projectId: proj.data.projectId,
645
+ dir: proj.dir, brainDir: legacy ? path.join(proj.dir, "brain") : proj.dir,
646
+ plansDir: path.join(proj.dir, "plans"),
647
+ repoPath: proj.data.repoPath, inited: true,
648
+ };
649
+ }
650
+ return { url: g.url, email: g.email, token: g.token, project: g.project, inited: false };
651
+ }
652
+
653
+ function findProjectForCwd() {
654
+ if (!fs.existsSync(PROJECTS_DIR)) return null;
655
+ const cwd = realPath(process.cwd());
656
+ let best = null;
657
+ for (const slug of fs.readdirSync(PROJECTS_DIR)) {
658
+ const dir = path.join(PROJECTS_DIR, slug);
659
+ const pj = path.join(dir, "project.json");
660
+ if (!fs.existsSync(pj)) continue;
661
+ let data; try { data = JSON.parse(fs.readFileSync(pj, "utf8")); } catch { continue; }
662
+ if (!data.repoPath) continue;
663
+ const rp = realPath(data.repoPath);
664
+ if (cwd === rp || cwd.startsWith(rp + path.sep)) {
665
+ if (!best || rp.length > realPath(best.data.repoPath).length) best = { dir, slug, data };
666
+ }
667
+ }
668
+ return best;
669
+ }
670
+
671
+ // ── init ──────────────────────────────────────────────────────
672
+ async function init(args) {
673
+ const flags = parseFlags(args);
674
+ const g = loadConfig();
675
+ if (!g) fail("run `scriptonia login` first, then `scriptonia init` in your repo");
676
+ const C = colors();
677
+ const cwd = process.cwd();
678
+
679
+ const existing = findProjectForCwd();
680
+ if (existing && !flags.force) {
681
+ writeRepoMd(existing.dir, scanRepo(cwd));
682
+ console.log(`\n ${C.g}✓${C.r} already initialized as ${C.b}${existing.data.name}${C.r} ${C.dim}(${existing.dir})${C.r}`);
683
+ console.log(` ${C.dim}re-scan from scratch:${C.r} ${C.g}scriptonia init --force${C.r}`);
684
+ console.log(` ${C.dim}next:${C.r} ${C.g}scriptonia add <files>${C.r}\n`);
685
+ return;
686
+ }
687
+
688
+ console.log(`\n ${C.dim}Scanning ${cwd}…${C.r}`);
689
+ const repoPath = realPath(cwd);
690
+ const scan = scanRepo(cwd);
691
+ const name = flags.name ?? scan.name;
692
+ const slug = slugify(name);
693
+ const dir = path.join(PROJECTS_DIR, slug);
694
+
695
+ let projectId, token;
696
+ if (existing?.data?.projectId) {
697
+ projectId = existing.data.projectId; token = existing.data.token;
698
+ } else {
699
+ const res = await fetch(`${g.url}/api/projects`, {
700
+ method: "POST",
701
+ headers: { authorization: `Bearer ${g.token}`, "content-type": "application/json" },
702
+ body: JSON.stringify({ name }),
703
+ });
704
+ if (!res.ok) fail(`could not create project (${res.status}) — is your login valid? try \`scriptonia login\``);
705
+ const data = await res.json();
706
+ projectId = data.projectId; token = data.token;
707
+ }
708
+
709
+ const pitch = flags.about ?? (await ask(` In one or two sentences — what is ${name} and who is it for?\n > `));
710
+
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
+
721
+ fs.writeFileSync(
722
+ path.join(dir, "project.json"),
723
+ JSON.stringify({ projectId, token, name, slug, repoPath, pitch: pitch || null, createdAt: new Date().toISOString() }, null, 2),
724
+ { mode: 0o600 },
725
+ );
726
+ writeProjectMd(dir, scan, pitch);
727
+ writeRepoMd(dir, scan);
728
+ writeAgentsBrainMd(dir, { name, pitch, stack: scan.stack }, []);
729
+ fs.writeFileSync(path.join(dir, ".gitignore"), "logs/\nproject.json\n");
730
+ injectAgentsMd(cwd);
731
+ installSkill();
732
+ appendLog(dir, "init", `Initialized brain for ${name} (${repoPath}) — stack: ${scan.stack}`);
733
+
734
+ console.log(`\n ${C.g}✓${C.r} Brain created for ${C.b}${name}${C.r} ${C.dim}→ ${dir}${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})`);
737
+ console.log(` ${C.g}✓${C.r} Wrote agent instructions ${C.dim}→ ./AGENTS.md${C.r}`);
738
+ console.log(`\n ${C.b}Feed it customer signal, then turn an issue into a plan:${C.r}\n`);
739
+ console.log(` ${C.g}scriptonia add${C.r} feedback.txt call.vtt ${C.dim}(or: scriptonia add "Issue #42: …")${C.r}`);
740
+ console.log(` ${C.g}scriptonia plan${C.r} "the issue you want to build"\n`);
741
+ }
742
+
743
+ // ── sync ──────────────────────────────────────────────────────
744
+ async function sync() {
745
+ const cfg = activeCreds();
746
+ const C = colors();
747
+ if (!cfg.inited) fail("run `scriptonia init` in this repo first");
748
+ const res = await fetch(`${cfg.url}/api/brain`, { headers: { authorization: `Bearer ${cfg.token}` } });
749
+ if (!res.ok) fail(`sync failed (${res.status})`);
750
+ const brain = await res.json();
751
+
752
+ const cdir = path.join(cfg.brainDir, "contexts");
753
+ fs.mkdirSync(cdir, { recursive: true });
754
+ for (const f of fs.readdirSync(cdir)) if (f.endsWith(".md")) fs.rmSync(path.join(cdir, f));
755
+ for (const c of brain.contexts) fs.writeFileSync(path.join(cdir, `${c.slug}.md`), renderContextFile(c));
756
+
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}`);
772
+ }
773
+
774
+ // ── plan (the money command) ──────────────────────────────────
775
+ async function plan(args) {
776
+ const cfg = activeCreds();
777
+ const C = colors();
778
+ const flags = parseFlags(args);
779
+
780
+ if (flags.refresh) {
781
+ const res = await fetch(`${cfg.url}/api/plan?slug=${encodeURIComponent(flags.refresh)}`, { headers: { authorization: `Bearer ${cfg.token}` } });
782
+ const data = await res.json();
783
+ if (!res.ok) fail(`refresh failed (${res.status}): ${JSON.stringify(data)}`);
784
+ return writePlan(cfg, data, flags);
785
+ }
786
+
787
+ const issue = args.filter((a) => !a.startsWith("--")).join(" ").trim();
788
+ if (!issue) fail('usage: scriptonia plan "<the issue>" [--out PLAN.md] [--refresh <slug>]');
789
+
790
+ const repoSummary = cfg.inited && fs.existsSync(path.join(cfg.brainDir, "REPO.md"))
791
+ ? fs.readFileSync(path.join(cfg.brainDir, "REPO.md"), "utf8").slice(0, 6000)
792
+ : buildRepoMd(scanRepo(process.cwd())).slice(0, 6000);
793
+
794
+ console.log(`\n ${C.dim}Planning "${issue}" — retrieving signal, checking decisions, drafting… (Opus, ~15s)${C.r}`);
795
+ const res = await fetch(`${cfg.url}/api/plan`, {
796
+ method: "POST",
797
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
798
+ body: JSON.stringify({ issue, repo_summary: repoSummary }),
799
+ });
800
+ const data = await res.json();
801
+ if (!res.ok) fail(`plan failed (${res.status}): ${JSON.stringify(data).slice(0, 200)}`);
802
+ writePlan(cfg, data, flags);
803
+ }
804
+
805
+ function writePlan(cfg, data, flags) {
806
+ const C = colors();
807
+ const out = flags.out ?? "PLAN.md";
808
+ fs.writeFileSync(path.resolve(process.cwd(), out), data.markdown);
809
+ if (cfg.inited) {
810
+ fs.mkdirSync(cfg.plansDir, { recursive: true });
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`);
816
+ }
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}`);
818
+ console.log(` ${C.dim}${(data.sources ?? []).length} cited signal(s)${data.contradictions ? `, ${data.contradictions} unresolved contradiction(s)` : ""}${C.r}`);
819
+ 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}`);
820
+ console.log(` ${C.dim}refine:${C.r} ${C.g}scriptonia comment plan/${data.slug} "…"${C.r} ${C.dim}(rewrites PLAN.md)${C.r}\n`);
821
+ }
822
+
823
+ // ── repo scan ─────────────────────────────────────────────────
824
+ function scanRepo(cwd) {
825
+ const readme = firstExisting(cwd, ["README.md", "readme.md", "Readme.md"]);
826
+ const readmeHead = readme ? fs.readFileSync(readme, "utf8").slice(0, 2000) : "";
827
+ let pkg = null;
828
+ try { pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")); } catch {}
829
+ const stack = detectStack(cwd, pkg);
830
+ const tree = buildTree(cwd, 3);
831
+ const name =
832
+ (pkg && pkg.name && String(pkg.name).replace(/^@[^/]+\//, "")) ||
833
+ gitRemoteName(cwd) ||
834
+ path.basename(cwd);
835
+ const scripts = pkg && pkg.scripts ? Object.keys(pkg.scripts) : [];
836
+ const deps = pkg ? Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }).slice(0, 20) : [];
837
+ const entryPoints = findEntryPoints(cwd);
838
+ return { name, readmeHead, stack, tree, scripts, deps, entryPoints, pkgDescription: pkg?.description || "" };
839
+ }
840
+
841
+ function detectStack(cwd, pkg) {
842
+ const has = (f) => fs.existsSync(path.join(cwd, f));
843
+ if (pkg) {
844
+ const d = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
845
+ if (d.next) return "Next.js / TypeScript";
846
+ if (d.react) return "React / TypeScript";
847
+ if (has("tsconfig.json")) return "TypeScript / Node";
848
+ return "Node / JavaScript";
849
+ }
850
+ if (has("go.mod")) return "Go";
851
+ if (has("Cargo.toml")) return "Rust";
852
+ if (has("requirements.txt") || has("pyproject.toml")) return "Python";
853
+ if (has("Gemfile")) return "Ruby";
854
+ if (has("pom.xml") || has("build.gradle")) return "Java/JVM";
855
+ if (has("composer.json")) return "PHP";
856
+ return "unknown";
857
+ }
858
+
859
+ function findEntryPoints(cwd) {
860
+ const cands = [
861
+ "src/app", "app", "pages", "src/index.ts", "src/index.js", "src/main.ts",
862
+ "main.py", "app.py", "cmd", "src/main.rs", "index.js", "server", "src/server",
863
+ ];
864
+ return cands.filter((c) => fs.existsSync(path.join(cwd, c)));
865
+ }
866
+
867
+ function buildTree(cwd, maxDepth) {
868
+ const IGNORE = new Set(["node_modules", ".git", ".next", "dist", "build", ".turbo", "coverage", ".vercel", "out", "__pycache__", ".venv", "target", "vendor"]);
869
+ const lines = [];
870
+ const walk = (d, depth, prefix) => {
871
+ if (depth > maxDepth) return;
872
+ let entries;
873
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
874
+ entries = entries.filter((e) => !e.name.startsWith(".") || e.name === ".env.example").filter((e) => !IGNORE.has(e.name));
875
+ entries.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1));
876
+ for (const e of entries.slice(0, 40)) {
877
+ lines.push(`${prefix}${e.name}${e.isDirectory() ? "/" : ""}`);
878
+ if (e.isDirectory()) walk(path.join(d, e.name), depth + 1, prefix + " ");
879
+ }
880
+ };
881
+ walk(cwd, 1, "");
882
+ return lines.slice(0, 200).join("\n");
883
+ }
884
+
885
+ function gitRemoteName(cwd) {
886
+ try {
887
+ const url = execSync("git remote get-url origin", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
888
+ const m = url.match(/([^/:]+?)(?:\.git)?$/);
889
+ return m ? m[1] : null;
890
+ } catch { return null; }
891
+ }
892
+
893
+ // ── brain file writers ────────────────────────────────────────
894
+ function writeProjectMd(dir, scan, pitch) {
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
+ "",
903
+ `# ${scan.name}`,
904
+ "",
905
+ pitch ? `> ${pitch}` : (scan.pkgDescription ? `> ${scan.pkgDescription}` : "> (describe this product with `scriptonia init --force`)"),
906
+ "",
907
+ `**Stack:** ${scan.stack}`,
908
+ scan.deps.length ? `**Key deps:** ${scan.deps.join(", ")}` : "",
909
+ scan.entryPoints.length ? `**Entry points:** ${scan.entryPoints.join(", ")}` : "",
910
+ "",
911
+ scan.readmeHead ? "## From the README\n\n" + scan.readmeHead : "",
912
+ "",
913
+ "---",
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);
917
+ }
918
+
919
+ function writeRepoMd(dir, 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 */ }
1060
+ }
1061
+
1062
+ function buildRepoMd(scan) {
1063
+ return [
1064
+ `# Repo map — ${scan.name}`,
1065
+ "",
1066
+ `**Stack:** ${scan.stack}`,
1067
+ scan.entryPoints.length ? `**Entry points:** ${scan.entryPoints.join(", ")}` : "",
1068
+ "",
1069
+ "## Tree (depth 3)",
1070
+ "```",
1071
+ scan.tree,
1072
+ "```",
1073
+ "",
1074
+ "*When you name files in a plan, use paths from this tree. Refresh with `scriptonia init --force`.*",
1075
+ ].filter(Boolean).join("\n");
1076
+ }
1077
+
1078
+ function renderContextFile(c) {
1079
+ const L = [`# ${c.title}`, "", `> **${c.type}** · ${c.segment} · strength ${c.strength}/10 · v${c.version}`, "", c.summary, ""];
1080
+ if (c.sources?.length) { L.push("## Sources"); for (const s of c.sources) L.push(`- \`${s.id.slice(0, 8)}\` ${s.source}${s.ref ? ` — ${s.ref}` : ""}`); L.push(""); }
1081
+ if (c.contradicts?.length) { L.push("## ⚠ Contradicts"); for (const x of c.contradicts) L.push(`- ${x}`); L.push(""); }
1082
+ return L.join("\n");
1083
+ }
1084
+
1085
+ // ── AGENTS.md injection (every agent reads this) ──────────────
1086
+ function injectAgentsMd(cwd) {
1087
+ const file = path.join(cwd, "AGENTS.md");
1088
+ const START = "<!-- scriptonia:start -->";
1089
+ const END = "<!-- scriptonia:end -->";
1090
+ const section = `${START}
1091
+ ## Scriptonia — product memory
1092
+
1093
+ This repo has a product brain (customer signal + prior decisions). Before building
1094
+ any user-facing change:
1095
+
1096
+ - \`scriptonia plan "<the issue>"\` → writes **PLAN.md** here. Execute PLAN.md
1097
+ exactly; respect its **Non-goals**; cite its \`sources\` ids in your PR.
1098
+ - \`scriptonia query "<topic>"\` → JSON: what customers asked for, contradictions,
1099
+ edge cases.
1100
+ - If a plan constraint is **UNRESOLVED**, stop and ask the human. If a step
1101
+ conflicts with the code, \`scriptonia comment plan/<slug> "<what you found>"\`
1102
+ and re-read PLAN.md.
1103
+ ${END}`;
1104
+
1105
+ let existing = "";
1106
+ try { existing = fs.readFileSync(file, "utf8"); } catch {}
1107
+ if (existing.includes(START) && existing.includes(END)) {
1108
+ const next = existing.replace(new RegExp(`${START}[\\s\\S]*?${END}`), section);
1109
+ fs.writeFileSync(file, next);
1110
+ } else if (existing.trim()) {
1111
+ fs.writeFileSync(file, existing.replace(/\s*$/, "") + "\n\n" + section + "\n");
1112
+ } else {
1113
+ fs.writeFileSync(file, "# Agent instructions\n\n" + section + "\n");
1114
+ }
1115
+ }
1116
+
1117
+ // ── misc ──────────────────────────────────────────────────────
1118
+ function slugify(s) {
1119
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 60) || "project";
1120
+ }
1121
+
1122
+ // Resolve symlinks (e.g. macOS /var → /private/var) so repo matching is stable
1123
+ // no matter which path the user cd'd through.
1124
+ function realPath(p) {
1125
+ try { return fs.realpathSync(p); } catch { return p; }
1126
+ }
1127
+
1128
+ function firstExisting(cwd, names) {
1129
+ for (const n of names) { const p = path.join(cwd, n); if (fs.existsSync(p)) return p; }
1130
+ return null;
1131
+ }
1132
+
1133
+ function ask(prompt) {
1134
+ if (!process.stdin.isTTY) return Promise.resolve("");
1135
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1136
+ return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a.trim()); }));
1137
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "scriptonia",
3
- "version": "0.2.0",
4
- "description": "Product memory for your coding agent. One command, browser sign-in, your agent reads your customers.",
3
+ "version": "0.6.0",
4
+ "description": "Turn customer signal into executable plans your coding agent runs. Issue in, PLAN.md out.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "scriptonia": "bin/scriptonia.mjs"