scriptonia 0.2.0 → 0.5.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,231 @@ 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 files = args.filter((a) => !a.startsWith("--"));
159
+ if (files.length === 0) {
160
+ fail(
161
+ 'usage: scriptonia add <file...> e.g. scriptonia add slack.txt notes.md call.vtt\n' +
162
+ " flags: --source slack|ticket|sales_call|voice_note|founder_note|email|notion|github\n" +
163
+ " --segment enterprise|mid_market|smb|free\n" +
164
+ " pipe: cat thread.txt | scriptonia add -",
165
+ );
166
+ }
167
+
168
+ const C = colors();
169
+ let created = 0, dup = 0, failed = 0;
170
+
171
+ for (const file of files) {
172
+ let body, ref, srcHint;
173
+ if (file === "-") {
174
+ body = fs.readFileSync(0, "utf8"); // stdin
175
+ ref = null;
176
+ srcHint = "founder_note";
177
+ } 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);
182
+ }
183
+ if (!body.trim()) { console.log(` ${C.w}skip${C.r} ${file} (empty)`); continue; }
184
+
185
+ const res = await fetch(`${cfg.url}/api/ingest`, {
186
+ method: "POST",
187
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
188
+ body: JSON.stringify({
189
+ source: flags.source ?? srcHint,
190
+ body,
191
+ externalRef: ref,
192
+ segment: flags.segment,
193
+ }),
194
+ });
195
+ 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++; }
199
+ }
200
+
201
+ console.log(`\n ${created} added${dup ? `, ${dup} already there` : ""}${failed ? `, ${failed} failed` : ""}.`);
202
+ 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`);
204
+ }
205
+ }
206
+
207
+ function detectSource(file) {
208
+ const ext = path.extname(file).toLowerCase();
209
+ if (ext === ".vtt" || ext === ".srt") return "sales_call";
210
+ if (ext === ".md") return "founder_note";
211
+ return "founder_note";
212
+ }
213
+
214
+ // ─────────────────────────────────────────────────────────────
215
+ // contexts — what the brain produced
216
+ // ─────────────────────────────────────────────────────────────
217
+ async function contexts() {
218
+ const cfg = activeCreds();
219
+ const C = colors();
220
+ const res = await fetch(`${cfg.url}/api/overview`, { headers: { authorization: `Bearer ${cfg.token}` } });
221
+ if (!res.ok) fail(`could not load contexts (${res.status})`);
222
+ const o = await res.json();
223
+
224
+ if (o.contexts_count === 0) {
225
+ console.log(`\n ${C.dim}No contexts yet.${C.r}`);
226
+ if ((o.signals?.total ?? 0) === 0) console.log(` Add signal first: ${C.g}scriptonia add <files>${C.r}\n`);
227
+ else console.log(` The brain is still indexing ${o.signals.total} signals — check back in a minute.\n`);
228
+ return;
229
+ }
230
+
231
+ console.log(`\n ${C.b}${o.contexts_count} context${o.contexts_count > 1 ? "s" : ""}${C.r} ${C.dim}· ${o.project}${C.r}\n`);
232
+ for (const c of o.contexts) {
233
+ const warn = c.contradicts ? ` ${C.w}⚠ contradicts${C.r}` : "";
234
+ 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}`);
235
+ console.log(` ${C.v}${cfg.url}/contexts/${c.slug}${C.r}`);
236
+ }
237
+ console.log(`\n ${C.dim}Turn any of these into a plan:${C.r} ${C.g}scriptonia plan "<the issue>"${C.r}\n`);
238
+ }
239
+
240
+ // ─────────────────────────────────────────────────────────────
241
+ // plans — list generated plans (find a slug to refine or re-fetch)
242
+ // ─────────────────────────────────────────────────────────────
243
+ async function plans() {
244
+ const cfg = activeCreds();
245
+ const C = colors();
246
+ const res = await fetch(`${cfg.url}/api/overview`, { headers: { authorization: `Bearer ${cfg.token}` } });
247
+ if (!res.ok) fail(`could not load plans (${res.status})`);
248
+ const o = await res.json();
249
+ const list = o.plans ?? [];
250
+ if (list.length === 0) {
251
+ console.log(`\n ${C.dim}No plans yet.${C.r} Generate one: ${C.g}scriptonia plan "<issue>"${C.r}\n`);
252
+ return;
253
+ }
254
+ console.log(`\n ${C.b}${list.length} plan${list.length > 1 ? "s" : ""}${C.r} ${C.dim}· ${o.project}${C.r}\n`);
255
+ for (const p of list) {
256
+ console.log(` ${C.b}${p.slug}${C.r} ${C.dim}v${p.version}${C.r}`);
257
+ console.log(` ${C.dim}${p.issue}${C.r}`);
258
+ console.log(` ${C.g}scriptonia plan --refresh ${p.slug}${C.r} ${C.dim}·${C.r} ${C.g}scriptonia comment plan/${p.slug} "…"${C.r}`);
259
+ }
260
+ console.log("");
261
+ }
262
+
263
+ // ─────────────────────────────────────────────────────────────
264
+ // comment — leave an inline note; the brain absorbs it (version bump)
265
+ // ─────────────────────────────────────────────────────────────
266
+ async function comment(args) {
267
+ const cfg = activeCreds();
268
+ const C = colors();
269
+ const flags = parseFlags(args);
270
+ const positional = args.filter((a) => !a.startsWith("--"));
271
+ const target = positional[0];
272
+ const text = positional.slice(1).join(" ").trim();
273
+ if (!target || !text) {
274
+ fail(
275
+ 'usage: scriptonia comment <slug> "<your note>" comment on a context\n' +
276
+ ' scriptonia comment plan/<slug> "<your note>" comment on a plan → regenerates PLAN.md\n' +
277
+ ' --as "Name" attribute the comment (defaults to you)',
278
+ );
279
+ }
280
+
281
+ // Comment on a PLAN → regenerate it (v+1) and rewrite PLAN.md.
282
+ if (target.startsWith("plan/")) {
283
+ const slug = target.slice(5);
284
+ console.log(`\n ${C.dim}Folding your note into "${slug}" and regenerating…${C.r}`);
285
+ const res = await fetch(`${cfg.url}/api/plan/comment`, {
286
+ method: "POST",
287
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
288
+ body: JSON.stringify({ slug, body: text, author: flags.as ?? cfg.email }),
289
+ });
290
+ const data = await res.json().catch(() => ({}));
291
+ if (res.status === 404) fail(`no plan "${slug}" — generate one first: scriptonia plan "<issue>"`);
292
+ if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data).slice(0, 200)}`);
293
+ writePlan(cfg, data, flags);
294
+ return;
295
+ }
296
+
297
+ // Comment on a CONTEXT → constrained-patch absorption.
298
+ const res = await fetch(`${cfg.url}/api/comment`, {
299
+ method: "POST",
300
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
301
+ body: JSON.stringify({ slug: target, body: text, author: flags.as ?? cfg.email }),
302
+ });
303
+ const data = await res.json().catch(() => ({}));
304
+ if (res.status === 404) fail(`no context "${target}" — see \`scriptonia contexts\``);
305
+ if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data)}`);
306
+
307
+ console.log(`\n ${C.g}✓${C.r} comment posted on ${C.b}${target}${C.r} ${C.dim}(v${data.from_version})${C.r}`);
308
+ 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
309
  }
88
310
 
89
311
  // ─────────────────────────────────────────────────────────────
90
312
  // query — what the agent (and you) call
91
313
  // ─────────────────────────────────────────────────────────────
92
314
  async function query(args) {
93
- const cfg = requireConfig();
315
+ const cfg = activeCreds();
94
316
  const flags = parseFlags(args);
95
317
  const q = args.filter((a) => !a.startsWith("--")).join(" ").trim();
96
318
  if (!q) fail('usage: scriptonia query "<topic>" [--window 30d] [--segment enterprise] [--json]');
@@ -112,7 +334,7 @@ async function query(args) {
112
334
  // link — resolve a signal_id to its full body
113
335
  // ─────────────────────────────────────────────────────────────
114
336
  async function link(args) {
115
- const cfg = requireConfig();
337
+ const cfg = activeCreds();
116
338
  const id = args.find((a) => !a.startsWith("--"));
117
339
  if (!id) fail('usage: scriptonia link "<signal_id>"');
118
340
  const res = await fetch(`${cfg.url}/api/link_back?signal_id=${encodeURIComponent(id)}`, {
@@ -159,33 +381,31 @@ function skillMarkdown() {
159
381
  return `---
160
382
  name: scriptonia
161
383
  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.
384
+ BEFORE building any user-facing change — a feature, a bug fix, a refactor with
385
+ UX impact — run \`scriptonia plan "<the issue>"\`. It writes PLAN.md here: a
386
+ hyper-focused, sourced, executable plan grounded in this project's real
387
+ customer signal, prior decisions, and human comments. Execute PLAN.md exactly.
167
388
  ---
168
389
 
169
- # Scriptonia — Product Memory
390
+ # Scriptonia — Product Memory → executable plans
170
391
 
171
- Before writing a plan for anything user-facing, call:
392
+ When asked to build/fix anything user-facing:
172
393
 
173
- \`\`\`sh
174
- scriptonia query "<the topic you're about to build>"
175
- \`\`\`
394
+ 1. Run \`scriptonia plan "<the issue>"\` — it writes **PLAN.md** in the repo.
395
+ 2. Execute PLAN.md **exactly**. Respect its **Non-goals** — do not add anything
396
+ outside the acceptance criteria.
397
+ 3. If a constraint is marked **UNRESOLVED**, STOP and surface it to the human.
398
+ 4. Your PR description must cite the \`sources\` signal ids from PLAN.md.
176
399
 
177
- Optional flags: \`--window 7d|30d|90d|all\` (default 30d), \`--segment enterprise|mid_market|smb|free\`.
400
+ If a plan step conflicts with what you find in the code, run
401
+ \`scriptonia comment plan/<slug> "<what you found>"\` and re-read the regenerated PLAN.md.
178
402
 
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>"\`.
403
+ To just look something up without a full plan: \`scriptonia query "<topic>"\`
404
+ (JSON: requirements with source_ids, contradictions, edge_cases). Quote a
405
+ customer verbatim with \`scriptonia link "<signal_id>"\`.
186
406
 
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.
407
+ Rule: never invent demand. If nothing in the plan/query supports a step, it
408
+ doesn't belong in the PR.
189
409
  `;
190
410
  }
191
411
 
@@ -193,7 +413,7 @@ source_id. If it can't, you're inventing demand — query first.
193
413
  // mcp — MCP stdio server (for MCP-native agents)
194
414
  // ─────────────────────────────────────────────────────────────
195
415
  async function mcp() {
196
- const cfg = requireConfig();
416
+ const cfg = activeCreds();
197
417
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
198
418
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
199
419
  const { z } = await import("zod");
@@ -275,8 +495,17 @@ function openBrowser(url) {
275
495
  try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); } catch { /* click the printed URL */ }
276
496
  }
277
497
 
498
+ function colors() {
499
+ const on = !!process.stdout.isTTY;
500
+ const w = (code) => (on ? code : "");
501
+ return {
502
+ dim: w("\x1b[2m"), b: w("\x1b[1m"), v: w("\x1b[35m"),
503
+ w: w("\x1b[33m"), g: w("\x1b[32m"), r: w("\x1b[0m"),
504
+ };
505
+ }
506
+
278
507
  function printPretty(d) {
279
- const c = { dim: "\x1b[2m", b: "\x1b[1m", v: "\x1b[35m", w: "\x1b[33m", g: "\x1b[32m", r: "\x1b[0m" };
508
+ const c = colors();
280
509
  console.log(`\n${c.b}${d.tldr ?? ""}${c.r}\n`);
281
510
  for (const req of d.requirements ?? []) {
282
511
  console.log(` ${c.g}●${c.r} ${c.b}${req.title}${c.r} ${c.dim}[${req.strength}]${c.r}`);
@@ -307,16 +536,371 @@ function escapeHtml(s) {
307
536
  }
308
537
  function fail(msg) { console.error(msg); process.exit(1); }
309
538
  function usage() {
310
- console.log(`scriptonia — product memory for your coding agent
539
+ console.log(`scriptonia — issue executable plan for your coding agent
540
+
541
+ THE LOOP
542
+ 1 SIGNAL scriptonia add <files> feed customer reality (text · md · vtt)
543
+ 2 PLAN scriptonia plan "<issue>" → PLAN.md: sourced, contradiction-checked
544
+ 3 BUILD claude "execute PLAN.md" your agent ships it → PR
545
+ 4 REFINE scriptonia comment plan/<slug> "…" a note regenerates the plan
546
+
547
+ SETUP
548
+ scriptonia login sign in (browser) — wires your agent, no keys
549
+ scriptonia init [per repo] give this repo its own brain + AGENTS.md
550
+
551
+ SIGNAL & PLANS
552
+ scriptonia add <file...> ingest files (--source, --segment; or cat x | scriptonia add -)
553
+ scriptonia plan "<issue>" generate PLAN.md (--out <file>, --refresh <slug>)
554
+ scriptonia plans list your plans (and their slugs)
555
+ scriptonia comment plan/<slug> "…" refine a plan → regenerates PLAN.md
556
+
557
+ INSPECT
558
+ scriptonia the loop + what's next
559
+ scriptonia contexts what the brain learned from your signal
560
+ scriptonia query "<topic>" quick lookup, no full plan (--json)
561
+ scriptonia link "<id>" a signal's full text
562
+ scriptonia sync write the brain to local files
563
+
564
+ ACCOUNT scriptonia status | logout | skill | mcp
565
+ `);
566
+ }
311
567
 
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)
568
+ // ═════════════════════════════════════════════════════════════
569
+ // v2 per-repo brain: init · sync · plan, pwd resolution
570
+ // ═════════════════════════════════════════════════════════════
571
+
572
+ /** Repo-local project when pwd is inside an initialized repo, else the global login. */
573
+ function activeCreds() {
574
+ const g = loadConfig();
575
+ if (!g) fail("run `scriptonia login` first");
576
+ const proj = findProjectForCwd();
577
+ if (proj) {
578
+ return {
579
+ url: g.url, email: g.email,
580
+ 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"),
582
+ repoPath: proj.data.repoPath, inited: true,
583
+ };
584
+ }
585
+ return { url: g.url, email: g.email, token: g.token, project: g.project, inited: false };
586
+ }
318
587
 
319
- login flags: --dev --email you@co.com (local testing; skips Google)
320
- query flags: --window 30d --segment enterprise --json
321
- `);
588
+ function findProjectForCwd() {
589
+ if (!fs.existsSync(PROJECTS_DIR)) return null;
590
+ const cwd = realPath(process.cwd());
591
+ let best = null;
592
+ for (const slug of fs.readdirSync(PROJECTS_DIR)) {
593
+ const dir = path.join(PROJECTS_DIR, slug);
594
+ const pj = path.join(dir, "project.json");
595
+ if (!fs.existsSync(pj)) continue;
596
+ let data; try { data = JSON.parse(fs.readFileSync(pj, "utf8")); } catch { continue; }
597
+ if (!data.repoPath) continue;
598
+ const rp = realPath(data.repoPath);
599
+ if (cwd === rp || cwd.startsWith(rp + path.sep)) {
600
+ if (!best || rp.length > realPath(best.data.repoPath).length) best = { dir, slug, data };
601
+ }
602
+ }
603
+ return best;
604
+ }
605
+
606
+ // ── init ──────────────────────────────────────────────────────
607
+ async function init(args) {
608
+ const flags = parseFlags(args);
609
+ const g = loadConfig();
610
+ if (!g) fail("run `scriptonia login` first, then `scriptonia init` in your repo");
611
+ const C = colors();
612
+ const cwd = process.cwd();
613
+
614
+ const existing = findProjectForCwd();
615
+ if (existing && !flags.force) {
616
+ writeRepoMd(existing.dir, scanRepo(cwd));
617
+ console.log(`\n ${C.g}✓${C.r} already initialized as ${C.b}${existing.data.name}${C.r} ${C.dim}(${existing.dir})${C.r}`);
618
+ console.log(` ${C.dim}re-scan from scratch:${C.r} ${C.g}scriptonia init --force${C.r}`);
619
+ console.log(` ${C.dim}next:${C.r} ${C.g}scriptonia add <files>${C.r}\n`);
620
+ return;
621
+ }
622
+
623
+ console.log(`\n ${C.dim}Scanning ${cwd}…${C.r}`);
624
+ const repoPath = realPath(cwd);
625
+ const scan = scanRepo(cwd);
626
+ const name = flags.name ?? scan.name;
627
+ const slug = slugify(name);
628
+ const dir = path.join(PROJECTS_DIR, slug);
629
+
630
+ let projectId, token;
631
+ if (existing?.data?.projectId) {
632
+ projectId = existing.data.projectId; token = existing.data.token;
633
+ } else {
634
+ const res = await fetch(`${g.url}/api/projects`, {
635
+ method: "POST",
636
+ headers: { authorization: `Bearer ${g.token}`, "content-type": "application/json" },
637
+ body: JSON.stringify({ name }),
638
+ });
639
+ if (!res.ok) fail(`could not create project (${res.status}) — is your login valid? try \`scriptonia login\``);
640
+ const data = await res.json();
641
+ projectId = data.projectId; token = data.token;
642
+ }
643
+
644
+ const pitch = flags.about ?? (await ask(` In one or two sentences — what is ${name} and who is it for?\n > `));
645
+
646
+ fs.mkdirSync(path.join(dir, "brain", "contexts"), { recursive: true });
647
+ fs.mkdirSync(path.join(dir, "plans"), { recursive: true });
648
+ fs.writeFileSync(
649
+ path.join(dir, "project.json"),
650
+ JSON.stringify({ projectId, token, name, slug, repoPath, pitch: pitch || null, createdAt: new Date().toISOString() }, null, 2),
651
+ { mode: 0o600 },
652
+ );
653
+ writeProjectMd(dir, scan, pitch);
654
+ writeRepoMd(dir, scan);
655
+ injectAgentsMd(cwd);
656
+ installSkill();
657
+
658
+ 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}`);
660
+ console.log(` ${C.g}✓${C.r} Wrote agent instructions ${C.dim}→ ./AGENTS.md${C.r}`);
661
+ 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`);
663
+ console.log(` ${C.g}scriptonia plan${C.r} "the issue you want to build"\n`);
664
+ }
665
+
666
+ // ── sync ──────────────────────────────────────────────────────
667
+ async function sync() {
668
+ const cfg = activeCreds();
669
+ const C = colors();
670
+ if (!cfg.inited) fail("run `scriptonia init` in this repo first");
671
+ const res = await fetch(`${cfg.url}/api/brain`, { headers: { authorization: `Bearer ${cfg.token}` } });
672
+ if (!res.ok) fail(`sync failed (${res.status})`);
673
+ const brain = await res.json();
674
+
675
+ const cdir = path.join(cfg.brainDir, "contexts");
676
+ fs.mkdirSync(cdir, { recursive: true });
677
+ for (const f of fs.readdirSync(cdir)) if (f.endsWith(".md")) fs.rmSync(path.join(cdir, f));
678
+ 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
+
681
+ console.log(` ${C.g}✓${C.r} synced ${brain.contexts.length} context(s), ${brain.decisions.length} decision(s) → ${C.dim}${cfg.brainDir}${C.r}`);
682
+ }
683
+
684
+ // ── plan (the money command) ──────────────────────────────────
685
+ async function plan(args) {
686
+ const cfg = activeCreds();
687
+ const C = colors();
688
+ const flags = parseFlags(args);
689
+
690
+ if (flags.refresh) {
691
+ const res = await fetch(`${cfg.url}/api/plan?slug=${encodeURIComponent(flags.refresh)}`, { headers: { authorization: `Bearer ${cfg.token}` } });
692
+ const data = await res.json();
693
+ if (!res.ok) fail(`refresh failed (${res.status}): ${JSON.stringify(data)}`);
694
+ return writePlan(cfg, data, flags);
695
+ }
696
+
697
+ const issue = args.filter((a) => !a.startsWith("--")).join(" ").trim();
698
+ if (!issue) fail('usage: scriptonia plan "<the issue>" [--out PLAN.md] [--refresh <slug>]');
699
+
700
+ const repoSummary = cfg.inited && fs.existsSync(path.join(cfg.brainDir, "REPO.md"))
701
+ ? fs.readFileSync(path.join(cfg.brainDir, "REPO.md"), "utf8").slice(0, 6000)
702
+ : buildRepoMd(scanRepo(process.cwd())).slice(0, 6000);
703
+
704
+ console.log(`\n ${C.dim}Planning "${issue}" — retrieving signal, checking decisions, drafting… (Opus, ~15s)${C.r}`);
705
+ const res = await fetch(`${cfg.url}/api/plan`, {
706
+ method: "POST",
707
+ headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
708
+ body: JSON.stringify({ issue, repo_summary: repoSummary }),
709
+ });
710
+ const data = await res.json();
711
+ if (!res.ok) fail(`plan failed (${res.status}): ${JSON.stringify(data).slice(0, 200)}`);
712
+ writePlan(cfg, data, flags);
713
+ }
714
+
715
+ function writePlan(cfg, data, flags) {
716
+ const C = colors();
717
+ const out = flags.out ?? "PLAN.md";
718
+ fs.writeFileSync(path.resolve(process.cwd(), out), data.markdown);
719
+ if (cfg.inited) {
720
+ fs.mkdirSync(cfg.plansDir, { recursive: true });
721
+ fs.writeFileSync(path.join(cfg.plansDir, `${data.slug}.md`), data.markdown);
722
+ }
723
+ 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
+ console.log(` ${C.dim}${(data.sources ?? []).length} cited signal(s)${data.contradictions ? `, ${data.contradictions} unresolved contradiction(s)` : ""}${C.r}`);
725
+ 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`);
727
+ }
728
+
729
+ // ── repo scan ─────────────────────────────────────────────────
730
+ function scanRepo(cwd) {
731
+ const readme = firstExisting(cwd, ["README.md", "readme.md", "Readme.md"]);
732
+ const readmeHead = readme ? fs.readFileSync(readme, "utf8").slice(0, 2000) : "";
733
+ let pkg = null;
734
+ try { pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")); } catch {}
735
+ const stack = detectStack(cwd, pkg);
736
+ const tree = buildTree(cwd, 3);
737
+ const name =
738
+ (pkg && pkg.name && String(pkg.name).replace(/^@[^/]+\//, "")) ||
739
+ gitRemoteName(cwd) ||
740
+ path.basename(cwd);
741
+ const scripts = pkg && pkg.scripts ? Object.keys(pkg.scripts) : [];
742
+ const deps = pkg ? Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }).slice(0, 20) : [];
743
+ const entryPoints = findEntryPoints(cwd);
744
+ return { name, readmeHead, stack, tree, scripts, deps, entryPoints, pkgDescription: pkg?.description || "" };
745
+ }
746
+
747
+ function detectStack(cwd, pkg) {
748
+ const has = (f) => fs.existsSync(path.join(cwd, f));
749
+ if (pkg) {
750
+ const d = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
751
+ if (d.next) return "Next.js / TypeScript";
752
+ if (d.react) return "React / TypeScript";
753
+ if (has("tsconfig.json")) return "TypeScript / Node";
754
+ return "Node / JavaScript";
755
+ }
756
+ if (has("go.mod")) return "Go";
757
+ if (has("Cargo.toml")) return "Rust";
758
+ if (has("requirements.txt") || has("pyproject.toml")) return "Python";
759
+ if (has("Gemfile")) return "Ruby";
760
+ if (has("pom.xml") || has("build.gradle")) return "Java/JVM";
761
+ if (has("composer.json")) return "PHP";
762
+ return "unknown";
763
+ }
764
+
765
+ function findEntryPoints(cwd) {
766
+ const cands = [
767
+ "src/app", "app", "pages", "src/index.ts", "src/index.js", "src/main.ts",
768
+ "main.py", "app.py", "cmd", "src/main.rs", "index.js", "server", "src/server",
769
+ ];
770
+ return cands.filter((c) => fs.existsSync(path.join(cwd, c)));
771
+ }
772
+
773
+ function buildTree(cwd, maxDepth) {
774
+ const IGNORE = new Set(["node_modules", ".git", ".next", "dist", "build", ".turbo", "coverage", ".vercel", "out", "__pycache__", ".venv", "target", "vendor"]);
775
+ const lines = [];
776
+ const walk = (d, depth, prefix) => {
777
+ if (depth > maxDepth) return;
778
+ let entries;
779
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
780
+ entries = entries.filter((e) => !e.name.startsWith(".") || e.name === ".env.example").filter((e) => !IGNORE.has(e.name));
781
+ entries.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1));
782
+ for (const e of entries.slice(0, 40)) {
783
+ lines.push(`${prefix}${e.name}${e.isDirectory() ? "/" : ""}`);
784
+ if (e.isDirectory()) walk(path.join(d, e.name), depth + 1, prefix + " ");
785
+ }
786
+ };
787
+ walk(cwd, 1, "");
788
+ return lines.slice(0, 200).join("\n");
789
+ }
790
+
791
+ function gitRemoteName(cwd) {
792
+ try {
793
+ const url = execSync("git remote get-url origin", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
794
+ const m = url.match(/([^/:]+?)(?:\.git)?$/);
795
+ return m ? m[1] : null;
796
+ } catch { return null; }
797
+ }
798
+
799
+ // ── brain file writers ────────────────────────────────────────
800
+ function writeProjectMd(dir, scan, pitch) {
801
+ const md = [
802
+ `# ${scan.name}`,
803
+ "",
804
+ pitch ? `> ${pitch}` : (scan.pkgDescription ? `> ${scan.pkgDescription}` : "> (describe this product with `scriptonia init --force`)"),
805
+ "",
806
+ `**Stack:** ${scan.stack}`,
807
+ scan.scripts.length ? `**Scripts:** ${scan.scripts.join(", ")}` : "",
808
+ scan.deps.length ? `**Key deps:** ${scan.deps.join(", ")}` : "",
809
+ scan.entryPoints.length ? `**Entry points:** ${scan.entryPoints.join(", ")}` : "",
810
+ "",
811
+ scan.readmeHead ? "## From the README\n\n" + scan.readmeHead : "",
812
+ "",
813
+ "---",
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);
817
+ }
818
+
819
+ function writeRepoMd(dir, scan) {
820
+ fs.mkdirSync(path.join(dir, "brain"), { recursive: true });
821
+ fs.writeFileSync(path.join(dir, "brain", "REPO.md"), buildRepoMd(scan));
822
+ }
823
+
824
+ function buildRepoMd(scan) {
825
+ return [
826
+ `# Repo map — ${scan.name}`,
827
+ "",
828
+ `**Stack:** ${scan.stack}`,
829
+ scan.entryPoints.length ? `**Entry points:** ${scan.entryPoints.join(", ")}` : "",
830
+ "",
831
+ "## Tree (depth 3)",
832
+ "```",
833
+ scan.tree,
834
+ "```",
835
+ "",
836
+ "*When you name files in a plan, use paths from this tree. Refresh with `scriptonia init --force`.*",
837
+ ].filter(Boolean).join("\n");
838
+ }
839
+
840
+ function renderContextFile(c) {
841
+ const L = [`# ${c.title}`, "", `> **${c.type}** · ${c.segment} · strength ${c.strength}/10 · v${c.version}`, "", c.summary, ""];
842
+ 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(""); }
843
+ if (c.contradicts?.length) { L.push("## ⚠ Contradicts"); for (const x of c.contradicts) L.push(`- ${x}`); L.push(""); }
844
+ return L.join("\n");
845
+ }
846
+
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
+ // ── AGENTS.md injection (every agent reads this) ──────────────
855
+ function injectAgentsMd(cwd) {
856
+ const file = path.join(cwd, "AGENTS.md");
857
+ const START = "<!-- scriptonia:start -->";
858
+ const END = "<!-- scriptonia:end -->";
859
+ const section = `${START}
860
+ ## Scriptonia — product memory
861
+
862
+ This repo has a product brain (customer signal + prior decisions). Before building
863
+ any user-facing change:
864
+
865
+ - \`scriptonia plan "<the issue>"\` → writes **PLAN.md** here. Execute PLAN.md
866
+ exactly; respect its **Non-goals**; cite its \`sources\` ids in your PR.
867
+ - \`scriptonia query "<topic>"\` → JSON: what customers asked for, contradictions,
868
+ edge cases.
869
+ - If a plan constraint is **UNRESOLVED**, stop and ask the human. If a step
870
+ conflicts with the code, \`scriptonia comment plan/<slug> "<what you found>"\`
871
+ and re-read PLAN.md.
872
+ ${END}`;
873
+
874
+ let existing = "";
875
+ try { existing = fs.readFileSync(file, "utf8"); } catch {}
876
+ if (existing.includes(START) && existing.includes(END)) {
877
+ const next = existing.replace(new RegExp(`${START}[\\s\\S]*?${END}`), section);
878
+ fs.writeFileSync(file, next);
879
+ } else if (existing.trim()) {
880
+ fs.writeFileSync(file, existing.replace(/\s*$/, "") + "\n\n" + section + "\n");
881
+ } else {
882
+ fs.writeFileSync(file, "# Agent instructions\n\n" + section + "\n");
883
+ }
884
+ }
885
+
886
+ // ── misc ──────────────────────────────────────────────────────
887
+ function slugify(s) {
888
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 60) || "project";
889
+ }
890
+
891
+ // Resolve symlinks (e.g. macOS /var → /private/var) so repo matching is stable
892
+ // no matter which path the user cd'd through.
893
+ function realPath(p) {
894
+ try { return fs.realpathSync(p); } catch { return p; }
895
+ }
896
+
897
+ function firstExisting(cwd, names) {
898
+ for (const n of names) { const p = path.join(cwd, n); if (fs.existsSync(p)) return p; }
899
+ return null;
900
+ }
901
+
902
+ function ask(prompt) {
903
+ if (!process.stdin.isTTY) return Promise.resolve("");
904
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
905
+ return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a.trim()); }));
322
906
  }
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.5.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"