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 +48 -28
- package/bin/scriptonia.mjs +637 -53
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# scriptonia
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
+
Left a review note? The plan regenerates:
|
|
19
25
|
|
|
20
26
|
```sh
|
|
21
|
-
scriptonia
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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+.
|
package/bin/scriptonia.mjs
CHANGED
|
@@ -1,34 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* scriptonia —
|
|
3
|
+
* scriptonia — issue → executable plan for your coding agent.
|
|
4
4
|
*
|
|
5
|
-
* scriptonia login
|
|
6
|
-
* scriptonia
|
|
7
|
-
* scriptonia
|
|
8
|
-
* scriptonia
|
|
9
|
-
* scriptonia
|
|
10
|
-
* scriptonia mcp
|
|
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
|
|
13
|
-
*
|
|
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
|
|
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 = {
|
|
31
|
-
|
|
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
|
-
|
|
83
|
-
console.log(
|
|
84
|
-
console.log(`
|
|
85
|
-
console.log(`\n
|
|
86
|
-
console.log(` scriptonia
|
|
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 =
|
|
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 =
|
|
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
|
|
163
|
-
UX impact
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
392
|
+
When asked to build/fix anything user-facing:
|
|
172
393
|
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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:
|
|
188
|
-
|
|
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 =
|
|
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 =
|
|
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 —
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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"
|