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 +48 -28
- package/bin/scriptonia.mjs +867 -52
- 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,292 @@ 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 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 =
|
|
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 =
|
|
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
|
|
163
|
-
UX impact
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
453
|
+
When asked to build/fix anything user-facing:
|
|
172
454
|
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>"\`.
|
|
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:
|
|
188
|
-
|
|
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 =
|
|
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 =
|
|
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 —
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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"
|