scriptonia 0.7.0 → 0.8.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 +17 -12
- package/bin/scriptonia.mjs +106 -86
- package/bin/verify.mjs +266 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -15,8 +15,8 @@ never see one), and your agent's `AGENTS.md` / skill now know about `scriptonia
|
|
|
15
15
|
plan`. Then, in your project:
|
|
16
16
|
|
|
17
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"
|
|
18
|
+
npx scriptonia add feedback.txt call.vtt notes.md # feed customer signal
|
|
19
|
+
npx scriptonia plan "let compliance filter the audit log by actor"
|
|
20
20
|
# → writes PLAN.md: goal, cited evidence, non-goals, real file paths, acceptance criteria
|
|
21
21
|
claude "execute PLAN.md" # your agent ships it → PR
|
|
22
22
|
```
|
|
@@ -24,7 +24,7 @@ claude "execute PLAN.md" # your agent ships it → PR
|
|
|
24
24
|
Left a review note? The plan regenerates:
|
|
25
25
|
|
|
26
26
|
```sh
|
|
27
|
-
scriptonia comment plan/<slug> "default to all actors, and gate behind the admin role"
|
|
27
|
+
npx scriptonia comment plan/<slug> "default to all actors, and gate behind the admin role"
|
|
28
28
|
# → PLAN.md v2 with your note folded in
|
|
29
29
|
```
|
|
30
30
|
|
|
@@ -32,19 +32,21 @@ scriptonia comment plan/<slug> "default to all actors, and gate behind the admin
|
|
|
32
32
|
|
|
33
33
|
| step | command | what happens |
|
|
34
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
|
|
38
|
-
| **REFINE** | `scriptonia comment plan/<slug> "…"` | A note regenerates the plan (v+1). A comment approving an override flips a contradiction to **RESOLVED**. |
|
|
35
|
+
| **SIGNAL** | `npx scriptonia add <files>` | Ingest text · md · vtt as customer signal (deduped, auto-sourced). |
|
|
36
|
+
| **PLAN** | `npx 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 implements against the plan's cited criteria and Non-goals fence. |
|
|
38
|
+
| **REFINE** | `npx scriptonia comment plan/<slug> "…"` | A note regenerates the plan (v+1). A comment approving an override flips a contradiction to **RESOLVED**. |
|
|
39
|
+
| **VERIFY** | `npx scriptonia verify` | Check the git diff, unresolved constraints, repo checks, and test coverage. Writes `.scriptonia/evals/<plan>.yaml`; exits 1 on BLOCK. |
|
|
39
40
|
|
|
40
41
|
## Commands
|
|
41
42
|
|
|
42
43
|
```
|
|
43
44
|
setup login init [per repo]
|
|
44
|
-
signal add <files> (--source, --segment, or cat x | scriptonia add -)
|
|
45
|
+
signal add <files> (--source, --segment, or cat x | npx scriptonia add -)
|
|
45
46
|
plan plan "<issue>" (--out <file>, --refresh <slug>)
|
|
46
47
|
plans list plans + their slugs
|
|
47
48
|
comment plan/<slug> "…" refine → regenerates PLAN.md
|
|
49
|
+
verify verify [--base <ref>] PLAN.md + git diff → PASS / WARN / BLOCK
|
|
48
50
|
inspect scriptonia the loop + what's next
|
|
49
51
|
contexts | query "<topic>" | link "<id>" | sync
|
|
50
52
|
account status | logout | skill | mcp
|
|
@@ -52,10 +54,13 @@ account status | logout | skill | mcp
|
|
|
52
54
|
|
|
53
55
|
## One brain per repo (for people juggling projects)
|
|
54
56
|
|
|
55
|
-
`scriptonia init` in a repo gives it its own brain at
|
|
57
|
+
`npx scriptonia init` in a repo gives it its own brain at
|
|
56
58
|
`~/.scriptonia/projects/<slug>/`, scans the code into a repo map (so plan steps
|
|
57
|
-
name real files), and writes an `AGENTS.md` section.
|
|
58
|
-
|
|
59
|
+
name real files), and writes an `AGENTS.md` section. Customer signal you submit
|
|
60
|
+
is stored and embedded on Scriptonia's servers; it is not written into the
|
|
61
|
+
repository. The local brain is a human-readable working surface. Initialization
|
|
62
|
+
does not upload the full codebase. When you explicitly run `verify`, the selected
|
|
63
|
+
PLAN.md and git diff are sent for analysis; the YAML regression case stays local.
|
|
59
64
|
|
|
60
65
|
Skip `init` if you have one product — `login → add → plan` uses your default
|
|
61
66
|
brain and scans whatever repo you run `plan` in.
|
|
@@ -71,4 +76,4 @@ Exposes `get_context`, `link_back`, `flag_contradiction` from your stored login.
|
|
|
71
76
|
## Self-hosting
|
|
72
77
|
|
|
73
78
|
Defaults to `https://scriptonia.dev`. Point elsewhere with
|
|
74
|
-
`SCRIPTONIA_URL=https://your.host scriptonia login` (or `--url`). Node 18+.
|
|
79
|
+
`SCRIPTONIA_URL=https://your.host npx scriptonia login` (or `--url`). Node 18+.
|
package/bin/scriptonia.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* scriptonia — issue → executable plan for your coding agent.
|
|
4
4
|
*
|
|
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)
|
|
5
|
+
* npx scriptonia login browser sign-in; wires your agent
|
|
6
|
+
* npx scriptonia add <files> feed customer signal (text · md · vtt)
|
|
7
|
+
* npx scriptonia plan "<issue>" → PLAN.md: sourced, contradiction-checked
|
|
8
|
+
* npx scriptonia comment plan/<slug> refine → regenerates PLAN.md
|
|
9
|
+
* npx scriptonia init [per repo] give a repo its own brain
|
|
10
|
+
* npx scriptonia mcp MCP over stdio (for MCP-native agents)
|
|
11
11
|
*
|
|
12
12
|
* After `login`, everything lives in ~/.scriptonia/. No env vars, no key
|
|
13
13
|
* pasting. Pure ESM, zero build step — runs on Node 18+.
|
|
@@ -19,6 +19,7 @@ import path from "node:path";
|
|
|
19
19
|
import readline from "node:readline";
|
|
20
20
|
import { spawn, execSync } from "node:child_process";
|
|
21
21
|
import { randomBytes } from "node:crypto";
|
|
22
|
+
import { runVerification } from "./verify.mjs";
|
|
22
23
|
|
|
23
24
|
const DEFAULT_URL = process.env.SCRIPTONIA_URL || "https://scriptonia.dev";
|
|
24
25
|
const CONFIG_DIR = path.join(os.homedir(), ".scriptonia");
|
|
@@ -30,7 +31,7 @@ const [, , cmd, ...rest] = process.argv;
|
|
|
30
31
|
const commands = {
|
|
31
32
|
login, init, sync,
|
|
32
33
|
add, ingest: add, push: add,
|
|
33
|
-
plan, plans, comment,
|
|
34
|
+
plan, plans, comment, verify,
|
|
34
35
|
query, contexts, link,
|
|
35
36
|
status, credits, logout, skill: reinstallSkill, mcp,
|
|
36
37
|
flow, guide: flow,
|
|
@@ -63,7 +64,7 @@ async function login(args) {
|
|
|
63
64
|
const project = u.searchParams.get("project") ?? "";
|
|
64
65
|
if (!token || returnedState !== state) {
|
|
65
66
|
res.writeHead(400, { "content-type": "text/html" });
|
|
66
|
-
res.end(pageHtml("Login failed", "State mismatch — run <code>scriptonia login</code> again."));
|
|
67
|
+
res.end(pageHtml("Login failed", "State mismatch — run <code>npx scriptonia login</code> again."));
|
|
67
68
|
return;
|
|
68
69
|
}
|
|
69
70
|
res.writeHead(200, { "content-type": "text/html" });
|
|
@@ -90,13 +91,13 @@ async function login(args) {
|
|
|
90
91
|
|
|
91
92
|
const C = colors();
|
|
92
93
|
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(` ${C.g}✓${C.r} Agent wired — skill + AGENTS.md know about \`npx scriptonia plan\``);
|
|
94
95
|
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}`);
|
|
96
|
+
console.log(` ${C.g}npx scriptonia add${C.r} feedback.txt call.vtt ${C.dim}feed customer signal${C.r}`);
|
|
97
|
+
console.log(` ${C.g}npx scriptonia plan${C.r} "the thing to build" ${C.dim}→ PLAN.md (sourced, checked)${C.r}`);
|
|
97
98
|
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`);
|
|
99
|
+
console.log(`\n ${C.dim}Try it on the sample data now:${C.r} ${C.g}npx scriptonia plan "filter audit log by actor"${C.r}`);
|
|
100
|
+
console.log(` ${C.dim}Working across repos? Run${C.r} ${C.g}npx scriptonia init${C.r} ${C.dim}in each for its own brain.${C.r}\n`);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -108,7 +109,7 @@ async function flow() {
|
|
|
108
109
|
if (!cfg) {
|
|
109
110
|
console.log(`\n ${C.b}SCRIPTONIA${C.r} · turn customer signal into plans your agent can execute\n`);
|
|
110
111
|
console.log(` One command sets everything up:\n`);
|
|
111
|
-
console.log(` ${C.g}scriptonia login${C.r}\n`);
|
|
112
|
+
console.log(` ${C.g}npx scriptonia login${C.r}\n`);
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
115
|
|
|
@@ -127,7 +128,7 @@ async function flow() {
|
|
|
127
128
|
const recentPlan = o?.plans?.[0]?.slug ?? null;
|
|
128
129
|
|
|
129
130
|
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
|
+
console.log(` ${C.dim}${cfg.email} · project ${o?.project ?? projName}${repo ? "" : C.dim + " (global — run `npx scriptonia init` in a repo for its own brain)"}${C.r}\n`);
|
|
131
132
|
|
|
132
133
|
const row = (n, name, desc, cmd) =>
|
|
133
134
|
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 : ""}`);
|
|
@@ -135,10 +136,11 @@ async function flow() {
|
|
|
135
136
|
const cr = o?.credits;
|
|
136
137
|
const creditStr = cr && cr.metered !== false ? ` · ${cr.plan} ${cr.used}/${cr.cap} credits` : "";
|
|
137
138
|
console.log(` ${C.dim}THE LOOP${C.r} ${C.dim}${s.total} signals · ${plansN} plans${creditStr}${C.r}`);
|
|
138
|
-
row("1", "SIGNAL", "feed customer reality (text·md·vtt)", `scriptonia add <files>`);
|
|
139
|
-
row("2", "PLAN", "issue → PLAN.md, sourced & checked", `scriptonia plan "<issue>"`);
|
|
139
|
+
row("1", "SIGNAL", "feed customer reality (text·md·vtt)", `npx scriptonia add <files>`);
|
|
140
|
+
row("2", "PLAN", "issue → PLAN.md, sourced & checked", `npx scriptonia plan "<issue>"`);
|
|
140
141
|
row("3", "BUILD", "your agent executes PLAN.md → PR", `claude "execute PLAN.md"`);
|
|
141
|
-
row("4", "REFINE", "a note regenerates the plan", `scriptonia comment plan/<slug> "…"`);
|
|
142
|
+
row("4", "REFINE", "a note regenerates the plan", `npx scriptonia comment plan/<slug> "…"`);
|
|
143
|
+
row("5", "VERIFY", "PR diff → PASS / WARN / BLOCK", `npx scriptonia verify`);
|
|
142
144
|
|
|
143
145
|
console.log(`\n ${C.b}NEXT${C.r} ${nextStep(s, plansN, recentPlan, repo, C)}`);
|
|
144
146
|
if (cr && cr.metered !== false && cr.available <= 0) {
|
|
@@ -149,10 +151,10 @@ async function flow() {
|
|
|
149
151
|
|
|
150
152
|
function nextStep(s, plansN, recentPlan, repo, C) {
|
|
151
153
|
if (s.total === 0)
|
|
152
|
-
return `${C.g}scriptonia add feedback.txt call.vtt${C.r} ${C.dim}— feed the brain customer signal${C.r}`;
|
|
154
|
+
return `${C.g}npx scriptonia add feedback.txt call.vtt${C.r} ${C.dim}— feed the brain customer signal${C.r}`;
|
|
153
155
|
if (plansN === 0)
|
|
154
|
-
return `${C.g}scriptonia plan "the thing you're about to build"${C.r} ${C.dim}— writes PLAN.md${C.r}`;
|
|
155
|
-
return `${C.g}claude "execute PLAN.md"${C.r} ${C.dim}—
|
|
156
|
+
return `${C.g}npx scriptonia plan "the thing you're about to build"${C.r} ${C.dim}— writes PLAN.md${C.r}`;
|
|
157
|
+
return `${C.g}claude "execute PLAN.md"${C.r} ${C.dim}— then gate the diff: ${C.r}${C.g}npx scriptonia verify${C.r}`;
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -164,11 +166,11 @@ async function add(args) {
|
|
|
164
166
|
const items = args.filter((a) => !a.startsWith("--"));
|
|
165
167
|
if (items.length === 0) {
|
|
166
168
|
fail(
|
|
167
|
-
'usage: scriptonia add <file...> e.g. scriptonia add slack.txt notes.md call.vtt\n' +
|
|
168
|
-
' scriptonia add "Issue #42: …" inline signal — also analyzed against the brain → context.md\n' +
|
|
169
|
+
'usage: npx scriptonia add <file...> e.g. npx scriptonia add slack.txt notes.md call.vtt\n' +
|
|
170
|
+
' npx scriptonia add "Issue #42: …" inline signal — also analyzed against the brain → context.md\n' +
|
|
169
171
|
" flags: --source slack|ticket|sales_call|voice_note|founder_note|email|notion|github\n" +
|
|
170
172
|
" --segment enterprise|mid_market|smb|free\n" +
|
|
171
|
-
" pipe: cat thread.txt | scriptonia add -",
|
|
173
|
+
" pipe: cat thread.txt | npx scriptonia add -",
|
|
172
174
|
);
|
|
173
175
|
}
|
|
174
176
|
|
|
@@ -252,12 +254,12 @@ async function add(args) {
|
|
|
252
254
|
if (cfg.inited) appendLog(cfg.dir, "add", `context.md synthesized (${(q.sources ?? []).length} cited, ${unresolved} contradictions)`);
|
|
253
255
|
}
|
|
254
256
|
} catch { /* analysis is best-effort; the signal is already ingested */ }
|
|
255
|
-
console.log(`\n ${C.b}Next:${C.r} ${C.g}scriptonia plan "${items[0].slice(0, 48)}${items[0].length > 48 ? "…" : ""}"${C.r}\n`);
|
|
257
|
+
console.log(`\n ${C.b}Next:${C.r} ${C.g}npx scriptonia plan "${items[0].slice(0, 48)}${items[0].length > 48 ? "…" : ""}"${C.r}\n`);
|
|
256
258
|
return;
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
if (created > 0) {
|
|
260
|
-
console.log(` ${C.dim}The brain is enriching them now. Then:${C.r} ${C.g}scriptonia plan "<issue>"${C.r}\n`);
|
|
262
|
+
console.log(` ${C.dim}The brain is enriching them now. Then:${C.r} ${C.g}npx scriptonia plan "<issue>"${C.r}\n`);
|
|
261
263
|
}
|
|
262
264
|
}
|
|
263
265
|
|
|
@@ -280,7 +282,7 @@ async function contexts() {
|
|
|
280
282
|
|
|
281
283
|
if (o.contexts_count === 0) {
|
|
282
284
|
console.log(`\n ${C.dim}No contexts yet.${C.r}`);
|
|
283
|
-
if ((o.signals?.total ?? 0) === 0) console.log(` Add signal first: ${C.g}scriptonia add <files>${C.r}\n`);
|
|
285
|
+
if ((o.signals?.total ?? 0) === 0) console.log(` Add signal first: ${C.g}npx scriptonia add <files>${C.r}\n`);
|
|
284
286
|
else console.log(` The brain is still indexing ${o.signals.total} signals — check back in a minute.\n`);
|
|
285
287
|
return;
|
|
286
288
|
}
|
|
@@ -291,7 +293,7 @@ async function contexts() {
|
|
|
291
293
|
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}`);
|
|
292
294
|
console.log(` ${C.v}${cfg.url}/contexts/${c.slug}${C.r}`);
|
|
293
295
|
}
|
|
294
|
-
console.log(`\n ${C.dim}Turn any of these into a plan:${C.r} ${C.g}scriptonia plan "<the issue>"${C.r}\n`);
|
|
296
|
+
console.log(`\n ${C.dim}Turn any of these into a plan:${C.r} ${C.g}npx scriptonia plan "<the issue>"${C.r}\n`);
|
|
295
297
|
}
|
|
296
298
|
|
|
297
299
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -305,14 +307,14 @@ async function plans() {
|
|
|
305
307
|
const o = await res.json();
|
|
306
308
|
const list = o.plans ?? [];
|
|
307
309
|
if (list.length === 0) {
|
|
308
|
-
console.log(`\n ${C.dim}No plans yet.${C.r} Generate one: ${C.g}scriptonia plan "<issue>"${C.r}\n`);
|
|
310
|
+
console.log(`\n ${C.dim}No plans yet.${C.r} Generate one: ${C.g}npx scriptonia plan "<issue>"${C.r}\n`);
|
|
309
311
|
return;
|
|
310
312
|
}
|
|
311
313
|
console.log(`\n ${C.b}${list.length} plan${list.length > 1 ? "s" : ""}${C.r} ${C.dim}· ${o.project}${C.r}\n`);
|
|
312
314
|
for (const p of list) {
|
|
313
315
|
console.log(` ${C.b}${p.slug}${C.r} ${C.dim}v${p.version}${C.r}`);
|
|
314
316
|
console.log(` ${C.dim}${p.issue}${C.r}`);
|
|
315
|
-
console.log(` ${C.g}scriptonia plan --refresh ${p.slug}${C.r} ${C.dim}·${C.r} ${C.g}scriptonia comment plan/${p.slug} "…"${C.r}`);
|
|
317
|
+
console.log(` ${C.g}npx scriptonia plan --refresh ${p.slug}${C.r} ${C.dim}·${C.r} ${C.g}npx scriptonia comment plan/${p.slug} "…"${C.r}`);
|
|
316
318
|
}
|
|
317
319
|
console.log("");
|
|
318
320
|
}
|
|
@@ -329,8 +331,8 @@ async function comment(args) {
|
|
|
329
331
|
const text = positional.slice(1).join(" ").trim();
|
|
330
332
|
if (!target || !text) {
|
|
331
333
|
fail(
|
|
332
|
-
'usage: scriptonia comment <slug> "<your note>" comment on a context\n' +
|
|
333
|
-
' scriptonia comment plan/<slug> "<your note>" comment on a plan → regenerates PLAN.md\n' +
|
|
334
|
+
'usage: npx scriptonia comment <slug> "<your note>" comment on a context\n' +
|
|
335
|
+
' npx scriptonia comment plan/<slug> "<your note>" comment on a plan → regenerates PLAN.md\n' +
|
|
334
336
|
' --as "Name" attribute the comment (defaults to you)',
|
|
335
337
|
);
|
|
336
338
|
}
|
|
@@ -346,7 +348,7 @@ async function comment(args) {
|
|
|
346
348
|
});
|
|
347
349
|
const data = await res.json().catch(() => ({}));
|
|
348
350
|
if (res.status === 402) failCredits(data);
|
|
349
|
-
if (res.status === 404) fail(`no plan "${slug}" — generate one first: scriptonia plan "<issue>"`);
|
|
351
|
+
if (res.status === 404) fail(`no plan "${slug}" — generate one first: npx scriptonia plan "<issue>"`);
|
|
350
352
|
if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data).slice(0, 200)}`);
|
|
351
353
|
// Approval-chain receipt: which human note produced which plan version.
|
|
352
354
|
if (cfg.inited) {
|
|
@@ -370,11 +372,11 @@ async function comment(args) {
|
|
|
370
372
|
});
|
|
371
373
|
const data = await res.json().catch(() => ({}));
|
|
372
374
|
if (res.status === 402) failCredits(data);
|
|
373
|
-
if (res.status === 404) fail(`no context "${target}" — see \`scriptonia contexts\``);
|
|
375
|
+
if (res.status === 404) fail(`no context "${target}" — see \`npx scriptonia contexts\``);
|
|
374
376
|
if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data)}`);
|
|
375
377
|
|
|
376
378
|
console.log(`\n ${C.g}✓${C.r} comment posted on ${C.b}${target}${C.r} ${C.dim}(v${data.from_version})${C.r}`);
|
|
377
|
-
console.log(` ${C.dim}the brain is absorbing it — it'll bump the version. check:${C.r} ${C.g}scriptonia contexts${C.r}\n`);
|
|
379
|
+
console.log(` ${C.dim}the brain is absorbing it — it'll bump the version. check:${C.r} ${C.g}npx scriptonia contexts${C.r}\n`);
|
|
378
380
|
}
|
|
379
381
|
|
|
380
382
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -384,7 +386,7 @@ async function query(args) {
|
|
|
384
386
|
const cfg = activeCreds();
|
|
385
387
|
const flags = parseFlags(args);
|
|
386
388
|
const q = args.filter((a) => !a.startsWith("--")).join(" ").trim();
|
|
387
|
-
if (!q) fail('usage: scriptonia query "<topic>" [--window 30d] [--segment enterprise] [--json]');
|
|
389
|
+
if (!q) fail('usage: npx scriptonia query "<topic>" [--window 30d] [--segment enterprise] [--json]');
|
|
388
390
|
|
|
389
391
|
const res = await fetch(`${cfg.url}/api/query`, {
|
|
390
392
|
method: "POST",
|
|
@@ -420,7 +422,7 @@ async function link(args) {
|
|
|
420
422
|
// ─────────────────────────────────────────────────────────────
|
|
421
423
|
async function status() {
|
|
422
424
|
const cfg = loadConfig();
|
|
423
|
-
if (!cfg) { console.log("not logged in — run `scriptonia login`"); return; }
|
|
425
|
+
if (!cfg) { console.log("not logged in — run `npx scriptonia login`"); return; }
|
|
424
426
|
const active = activeCreds();
|
|
425
427
|
const C = colors();
|
|
426
428
|
console.log(`signed in as ${cfg.email}`);
|
|
@@ -446,7 +448,7 @@ async function credits() {
|
|
|
446
448
|
if (c.metered === false) { console.log(`\n ${C.dim}This project isn't metered.${C.r}\n`); return; }
|
|
447
449
|
console.log(`\n ${C.b}${c.plan.toUpperCase()}${C.r} · ${cfg.project}`);
|
|
448
450
|
console.log(` ${creditBar(c.used, c.cap, C)} ${C.b}${c.available}${C.r} of ${c.cap} credits left ${C.dim}(resets ${c.resets_at})${C.r}`);
|
|
449
|
-
console.log(`\n ${C.dim}Plans &
|
|
451
|
+
console.log(`\n ${C.dim}Plans, comments & verification cost 5 · queries 1 · adding signal is free.${C.r}`);
|
|
450
452
|
if (c.available <= 0) console.log(` ${C.w}Out of credits.${C.r} ${c.topup}`);
|
|
451
453
|
else console.log(` ${C.dim}Need more? ${c.topup}${C.r}`);
|
|
452
454
|
console.log("");
|
|
@@ -505,7 +507,7 @@ function skillMarkdown() {
|
|
|
505
507
|
name: scriptonia
|
|
506
508
|
description: >
|
|
507
509
|
BEFORE building any user-facing change — a feature, a bug fix, a refactor with
|
|
508
|
-
UX impact — run \`scriptonia plan "<the issue>"\`. It writes PLAN.md here: a
|
|
510
|
+
UX impact — run \`npx scriptonia plan "<the issue>"\`. It writes PLAN.md here: a
|
|
509
511
|
hyper-focused, sourced, executable plan grounded in this project's real
|
|
510
512
|
customer signal, prior decisions, and human comments. Execute PLAN.md exactly.
|
|
511
513
|
---
|
|
@@ -514,18 +516,20 @@ description: >
|
|
|
514
516
|
|
|
515
517
|
When asked to build/fix anything user-facing:
|
|
516
518
|
|
|
517
|
-
1. Run \`scriptonia plan "<the issue>"\` — it writes **PLAN.md** in the repo.
|
|
519
|
+
1. Run \`npx scriptonia plan "<the issue>"\` — it writes **PLAN.md** in the repo.
|
|
518
520
|
2. Execute PLAN.md **exactly**. Respect its **Non-goals** — do not add anything
|
|
519
521
|
outside the acceptance criteria.
|
|
520
522
|
3. If a constraint is marked **UNRESOLVED**, STOP and surface it to the human.
|
|
521
|
-
4.
|
|
523
|
+
4. Run \`npx scriptonia verify\` after implementation. Fix every BLOCK before
|
|
524
|
+
merge and preserve the generated regression case.
|
|
525
|
+
5. Your PR description must cite the \`sources\` signal ids from PLAN.md.
|
|
522
526
|
|
|
523
527
|
If a plan step conflicts with what you find in the code, run
|
|
524
|
-
\`scriptonia comment plan/<slug> "<what you found>"\` and re-read the regenerated PLAN.md.
|
|
528
|
+
\`npx scriptonia comment plan/<slug> "<what you found>"\` and re-read the regenerated PLAN.md.
|
|
525
529
|
|
|
526
|
-
To just look something up without a full plan: \`scriptonia query "<topic>"\`
|
|
530
|
+
To just look something up without a full plan: \`npx scriptonia query "<topic>"\`
|
|
527
531
|
(JSON: requirements with source_ids, contradictions, edge_cases). Quote a
|
|
528
|
-
customer verbatim with \`scriptonia link "<signal_id>"\`.
|
|
532
|
+
customer verbatim with \`npx scriptonia link "<signal_id>"\`.
|
|
529
533
|
|
|
530
534
|
Rule: never invent demand. If nothing in the plan/query supports a step, it
|
|
531
535
|
doesn't belong in the PR.
|
|
@@ -588,7 +592,7 @@ function loadConfig() {
|
|
|
588
592
|
}
|
|
589
593
|
function requireConfig() {
|
|
590
594
|
const cfg = loadConfig();
|
|
591
|
-
if (!cfg) fail("not logged in — run `scriptonia login`");
|
|
595
|
+
if (!cfg) fail("not logged in — run `npx scriptonia login`");
|
|
592
596
|
return cfg;
|
|
593
597
|
}
|
|
594
598
|
function saveConfig(cfg) {
|
|
@@ -662,31 +666,37 @@ function usage() {
|
|
|
662
666
|
console.log(`scriptonia — issue → executable plan for your coding agent
|
|
663
667
|
|
|
664
668
|
THE LOOP
|
|
665
|
-
1 SIGNAL scriptonia add <files> feed customer reality (text · md · vtt)
|
|
666
|
-
2 PLAN scriptonia plan "<issue>" → PLAN.md: sourced, contradiction-checked
|
|
669
|
+
1 SIGNAL npx scriptonia add <files> feed customer reality (text · md · vtt)
|
|
670
|
+
2 PLAN npx scriptonia plan "<issue>" → PLAN.md: sourced, contradiction-checked
|
|
667
671
|
3 BUILD claude "execute PLAN.md" your agent ships it → PR
|
|
668
|
-
4 REFINE scriptonia comment plan/<slug> "…" a note regenerates the plan
|
|
672
|
+
4 REFINE npx scriptonia comment plan/<slug> "…" a note regenerates the plan
|
|
669
673
|
|
|
670
674
|
SETUP
|
|
671
|
-
scriptonia login sign in (browser) — wires your agent, no keys
|
|
672
|
-
scriptonia init [per repo] give this repo its own brain + AGENTS.md
|
|
675
|
+
npx scriptonia login sign in (browser) — wires your agent, no keys
|
|
676
|
+
npx scriptonia init [per repo] give this repo its own brain + AGENTS.md
|
|
673
677
|
|
|
674
678
|
SIGNAL & PLANS
|
|
675
|
-
scriptonia add <file...> ingest files (--source, --segment; or cat x | scriptonia add -)
|
|
676
|
-
scriptonia plan "<issue>" generate PLAN.md (--out <file>, --refresh <slug>)
|
|
677
|
-
scriptonia plans list your plans (and their slugs)
|
|
678
|
-
scriptonia comment plan/<slug> "…" refine a plan → regenerates PLAN.md
|
|
679
|
+
npx scriptonia add <file...> ingest files (--source, --segment; or cat x | npx scriptonia add -)
|
|
680
|
+
npx scriptonia plan "<issue>" generate PLAN.md (--out <file>, --refresh <slug>)
|
|
681
|
+
npx scriptonia plans list your plans (and their slugs)
|
|
682
|
+
npx scriptonia comment plan/<slug> "…" refine a plan → regenerates PLAN.md
|
|
683
|
+
npx scriptonia verify check the git diff against PLAN.md
|
|
679
684
|
|
|
680
685
|
INSPECT
|
|
681
|
-
scriptonia
|
|
682
|
-
scriptonia contexts what the brain learned from your signal
|
|
683
|
-
scriptonia query "<topic>" quick lookup, no full plan (--json)
|
|
684
|
-
scriptonia link "<id>"
|
|
685
|
-
scriptonia sync write the brain to local files
|
|
686
|
-
scriptonia credits plan, balance, reset date
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
686
|
+
npx scriptonia the loop + what's next
|
|
687
|
+
npx scriptonia contexts what the brain learned from your signal
|
|
688
|
+
npx scriptonia query "<topic>" quick lookup, no full plan (--json)
|
|
689
|
+
npx scriptonia link "<id>" a signal's full text
|
|
690
|
+
npx scriptonia sync write the brain to local files
|
|
691
|
+
npx scriptonia credits plan, balance, reset date
|
|
692
|
+
|
|
693
|
+
VERIFY
|
|
694
|
+
npx scriptonia verify [--plan PLAN.md] [--base origin/main] [--diff patch.txt]
|
|
695
|
+
[--skip-checks] [--out .scriptonia/evals/<case>.yaml]
|
|
696
|
+
Exit 0: PASS/WARN · Exit 1: BLOCK or operational failure
|
|
697
|
+
|
|
698
|
+
ACCOUNT npx scriptonia status | npx scriptonia logout | npx scriptonia skill | npx scriptonia mcp
|
|
699
|
+
Plans, comments & verification cost 5 credits · queries 1 · adding signal is free.
|
|
690
700
|
`);
|
|
691
701
|
}
|
|
692
702
|
|
|
@@ -697,7 +707,7 @@ function usage() {
|
|
|
697
707
|
/** Repo-local project when pwd is inside an initialized repo, else the global login. */
|
|
698
708
|
function activeCreds() {
|
|
699
709
|
const g = loadConfig();
|
|
700
|
-
if (!g) fail("run `scriptonia login` first");
|
|
710
|
+
if (!g) fail("run `npx scriptonia login` first");
|
|
701
711
|
const proj = findProjectForCwd();
|
|
702
712
|
if (proj) {
|
|
703
713
|
// v0.6 brains are flat (REPO.md etc. at the project dir root); older ones
|
|
@@ -736,7 +746,7 @@ function findProjectForCwd() {
|
|
|
736
746
|
async function init(args) {
|
|
737
747
|
const flags = parseFlags(args);
|
|
738
748
|
const g = loadConfig();
|
|
739
|
-
if (!g) fail("run `scriptonia login` first, then `scriptonia init` in your repo");
|
|
749
|
+
if (!g) fail("run `npx scriptonia login` first, then `npx scriptonia init` in your repo");
|
|
740
750
|
const C = colors();
|
|
741
751
|
const cwd = process.cwd();
|
|
742
752
|
|
|
@@ -744,8 +754,8 @@ async function init(args) {
|
|
|
744
754
|
if (existing && !flags.force) {
|
|
745
755
|
writeRepoMd(existing.dir, scanRepo(cwd));
|
|
746
756
|
console.log(`\n ${C.g}✓${C.r} already initialized as ${C.b}${existing.data.name}${C.r} ${C.dim}(${existing.dir})${C.r}`);
|
|
747
|
-
console.log(` ${C.dim}re-scan from scratch:${C.r} ${C.g}scriptonia init --force${C.r}`);
|
|
748
|
-
console.log(` ${C.dim}next:${C.r} ${C.g}scriptonia add <files>${C.r}\n`);
|
|
757
|
+
console.log(` ${C.dim}re-scan from scratch:${C.r} ${C.g}npx scriptonia init --force${C.r}`);
|
|
758
|
+
console.log(` ${C.dim}next:${C.r} ${C.g}npx scriptonia add <files>${C.r}\n`);
|
|
749
759
|
return;
|
|
750
760
|
}
|
|
751
761
|
|
|
@@ -765,7 +775,7 @@ async function init(args) {
|
|
|
765
775
|
headers: { authorization: `Bearer ${g.token}`, "content-type": "application/json" },
|
|
766
776
|
body: JSON.stringify({ name }),
|
|
767
777
|
});
|
|
768
|
-
if (!res.ok) fail(`could not create project (${res.status}) — is your login valid? try \`scriptonia login\``);
|
|
778
|
+
if (!res.ok) fail(`could not create project (${res.status}) — is your login valid? try \`npx scriptonia login\``);
|
|
769
779
|
const data = await res.json();
|
|
770
780
|
projectId = data.projectId; token = data.token;
|
|
771
781
|
}
|
|
@@ -800,15 +810,15 @@ async function init(args) {
|
|
|
800
810
|
console.log(` ${C.g}✓${C.r} Scanned repo (${scan.stack})`);
|
|
801
811
|
console.log(` ${C.g}✓${C.r} Wrote agent instructions ${C.dim}→ ./AGENTS.md${C.r}`);
|
|
802
812
|
console.log(`\n ${C.b}Feed it customer signal, then turn an issue into a plan:${C.r}\n`);
|
|
803
|
-
console.log(` ${C.g}scriptonia add${C.r} feedback.txt call.vtt ${C.dim}(or: scriptonia add "Issue #42: …")${C.r}`);
|
|
804
|
-
console.log(` ${C.g}scriptonia plan${C.r} "the issue you want to build"\n`);
|
|
813
|
+
console.log(` ${C.g}npx scriptonia add${C.r} feedback.txt call.vtt ${C.dim}(or: npx scriptonia add "Issue #42: …")${C.r}`);
|
|
814
|
+
console.log(` ${C.g}npx scriptonia plan${C.r} "the issue you want to build"\n`);
|
|
805
815
|
}
|
|
806
816
|
|
|
807
817
|
// ── sync ──────────────────────────────────────────────────────
|
|
808
818
|
async function sync() {
|
|
809
819
|
const cfg = activeCreds();
|
|
810
820
|
const C = colors();
|
|
811
|
-
if (!cfg.inited) fail("run `scriptonia init` in this repo first");
|
|
821
|
+
if (!cfg.inited) fail("run `npx scriptonia init` in this repo first");
|
|
812
822
|
const res = await fetch(`${cfg.url}/api/brain`, { headers: { authorization: `Bearer ${cfg.token}` } });
|
|
813
823
|
if (!res.ok) fail(`sync failed (${res.status})`);
|
|
814
824
|
const brain = await res.json();
|
|
@@ -849,7 +859,7 @@ async function plan(args) {
|
|
|
849
859
|
}
|
|
850
860
|
|
|
851
861
|
const issue = args.filter((a) => !a.startsWith("--")).join(" ").trim();
|
|
852
|
-
if (!issue) fail('usage: scriptonia plan "<the issue>" [--out PLAN.md] [--refresh <slug>]');
|
|
862
|
+
if (!issue) fail('usage: npx scriptonia plan "<the issue>" [--out PLAN.md] [--refresh <slug>]');
|
|
853
863
|
|
|
854
864
|
const repoSummary = cfg.inited && fs.existsSync(path.join(cfg.brainDir, "REPO.md"))
|
|
855
865
|
? fs.readFileSync(path.join(cfg.brainDir, "REPO.md"), "utf8").slice(0, 6000)
|
|
@@ -867,6 +877,12 @@ async function plan(args) {
|
|
|
867
877
|
writePlan(cfg, data, flags);
|
|
868
878
|
}
|
|
869
879
|
|
|
880
|
+
// ── verify — PLAN.md + git diff → separate CI gates ──────────────────
|
|
881
|
+
async function verify(args) {
|
|
882
|
+
const result = await runVerification(args, { cfg: activeCreds(), cwd: process.cwd() });
|
|
883
|
+
process.exitCode = result.exitCode;
|
|
884
|
+
}
|
|
885
|
+
|
|
870
886
|
function writePlan(cfg, data, flags) {
|
|
871
887
|
const C = colors();
|
|
872
888
|
const out = flags.out ?? "PLAN.md";
|
|
@@ -882,7 +898,7 @@ function writePlan(cfg, data, flags) {
|
|
|
882
898
|
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}`);
|
|
883
899
|
console.log(` ${C.dim}${(data.sources ?? []).length} cited signal(s)${data.contradictions ? `, ${data.contradictions} unresolved contradiction(s)` : ""}${C.r}`);
|
|
884
900
|
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}`);
|
|
885
|
-
console.log(` ${C.dim}refine:${C.r} ${C.g}scriptonia comment plan/${data.slug} "…"${C.r} ${C.dim}(rewrites PLAN.md)${C.r}`);
|
|
901
|
+
console.log(` ${C.dim}refine:${C.r} ${C.g}npx scriptonia comment plan/${data.slug} "…"${C.r} ${C.dim}(rewrites PLAN.md)${C.r}`);
|
|
886
902
|
if (data.credits) {
|
|
887
903
|
const cr = data.credits;
|
|
888
904
|
const low = cr.cap > 0 && cr.cap - cr.used <= Math.max(2, Math.round(cr.cap * 0.2));
|
|
@@ -973,7 +989,7 @@ function writeProjectMd(dir, scan, pitch) {
|
|
|
973
989
|
"",
|
|
974
990
|
`# ${scan.name}`,
|
|
975
991
|
"",
|
|
976
|
-
pitch ? `> ${pitch}` : (scan.pkgDescription ? `> ${scan.pkgDescription}` : "> (describe this product with `scriptonia init --force`)"),
|
|
992
|
+
pitch ? `> ${pitch}` : (scan.pkgDescription ? `> ${scan.pkgDescription}` : "> (describe this product with `npx scriptonia init --force`)"),
|
|
977
993
|
"",
|
|
978
994
|
`**Stack:** ${scan.stack}`,
|
|
979
995
|
scan.deps.length ? `**Key deps:** ${scan.deps.join(", ")}` : "",
|
|
@@ -982,7 +998,7 @@ function writeProjectMd(dir, scan, pitch) {
|
|
|
982
998
|
scan.readmeHead ? "## From the README\n\n" + scan.readmeHead : "",
|
|
983
999
|
"",
|
|
984
1000
|
"---",
|
|
985
|
-
"*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`.*",
|
|
1001
|
+
"*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 `npx scriptonia init`.*",
|
|
986
1002
|
].filter((l) => l !== "" && l !== null).join("\n");
|
|
987
1003
|
fs.writeFileSync(path.join(dir, "PROJECT.md"), md);
|
|
988
1004
|
}
|
|
@@ -1009,25 +1025,27 @@ function writeAgentsBrainMd(dir, meta, decisions) {
|
|
|
1009
1025
|
"",
|
|
1010
1026
|
"## 1. Identity",
|
|
1011
1027
|
"",
|
|
1012
|
-
meta.pitch ? `${meta.pitch}` : "(run `scriptonia init --force` to describe this product)",
|
|
1028
|
+
meta.pitch ? `${meta.pitch}` : "(run `npx scriptonia init --force` to describe this product)",
|
|
1013
1029
|
meta.stack ? `\nStack: ${meta.stack}` : "",
|
|
1014
1030
|
"",
|
|
1015
1031
|
"## 2. Workflow — non-negotiable",
|
|
1016
1032
|
"",
|
|
1017
|
-
'1. Before building any user-facing change, run `scriptonia plan "<the issue>"`.',
|
|
1033
|
+
'1. Before building any user-facing change, run `npx scriptonia plan "<the issue>"`.',
|
|
1018
1034
|
" It writes PLAN.md, grounded in real customer signal and checked against",
|
|
1019
1035
|
" the rules below.",
|
|
1020
1036
|
"2. Execute PLAN.md **exactly**. Its **Non-goals** section is a fence — do not",
|
|
1021
1037
|
" build past it.",
|
|
1022
1038
|
"3. Any constraint marked **⚠ UNRESOLVED** is an approval gate: STOP and get a",
|
|
1023
|
-
' human comment (`scriptonia comment plan/<slug> "…"`) before writing code.',
|
|
1039
|
+
' human comment (`npx scriptonia comment plan/<slug> "…"`) before writing code.',
|
|
1024
1040
|
"4. Cite PLAN.md's `sources` signal ids in your PR description.",
|
|
1041
|
+
"5. After implementation, run `npx scriptonia verify`; fix every BLOCK",
|
|
1042
|
+
" before merge and preserve the generated `.scriptonia/evals/*.yaml` case.",
|
|
1025
1043
|
"",
|
|
1026
1044
|
"## 3. Non-negotiable constraints (from the decisions corpus)",
|
|
1027
1045
|
"",
|
|
1028
1046
|
];
|
|
1029
1047
|
if (decisions.length === 0) {
|
|
1030
|
-
L.push("_None synced yet — run `scriptonia sync` after decisions exist. The server_", "_still enforces them during `plan` regardless._");
|
|
1048
|
+
L.push("_None synced yet — run `npx scriptonia sync` after decisions exist. The server_", "_still enforces them during `plan` regardless._");
|
|
1031
1049
|
} else {
|
|
1032
1050
|
for (const d of decisions) {
|
|
1033
1051
|
L.push(`### ${d.title}${d.ref ? ` (${d.ref})` : ""}`, "", d.body, "");
|
|
@@ -1058,7 +1076,7 @@ function renderRule(d) {
|
|
|
1058
1076
|
"check: |",
|
|
1059
1077
|
...String(d.body).split("\n").map((l) => ` ${l}`),
|
|
1060
1078
|
"enforcement: |",
|
|
1061
|
-
" Server-side during `scriptonia plan`: any issue conflicting with this rule",
|
|
1079
|
+
" Server-side during `npx scriptonia plan`: any issue conflicting with this rule",
|
|
1062
1080
|
" is marked ⚠ UNRESOLVED in PLAN.md until a human comment approves the",
|
|
1063
1081
|
" override (recorded in comments/plan/<slug>/).",
|
|
1064
1082
|
"---",
|
|
@@ -1098,7 +1116,7 @@ function renderContextLive(issue, q, project, signalId) {
|
|
|
1098
1116
|
if (cons.length) {
|
|
1099
1117
|
L.push("## ⚠ CONTRADICTION GATE — UNRESOLVED");
|
|
1100
1118
|
for (const c of cons) L.push(`- ${c.detail} (${c.decision_ref})`);
|
|
1101
|
-
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.", "");
|
|
1119
|
+
L.push("", "**STATUS: ⚠ UNRESOLVED.** A human must approve the override before any plan", "built on this context is executed. `npx scriptonia plan` will carry this gate.", "");
|
|
1102
1120
|
} else {
|
|
1103
1121
|
L.push("## Contradiction gate", "", "✓ No conflicts with prior decisions on record.", "");
|
|
1104
1122
|
}
|
|
@@ -1113,7 +1131,7 @@ function renderContextLive(issue, q, project, signalId) {
|
|
|
1113
1131
|
for (const s of q.sources ?? []) {
|
|
1114
1132
|
L.push(`- \`sig_${s.signal_id.slice(0, 8)}\` ${s.source}${s.ref ? ` — ${s.ref}` : ""} _(similarity ${s.similarity})_`);
|
|
1115
1133
|
}
|
|
1116
|
-
L.push("", "---", `*Next: \`scriptonia plan "${issue.slice(0, 60)}${issue.length > 60 ? "…" : ""}"\` → PLAN.md.*`);
|
|
1134
|
+
L.push("", "---", `*Next: \`npx scriptonia plan "${issue.slice(0, 60)}${issue.length > 60 ? "…" : ""}"\` → PLAN.md.*`);
|
|
1117
1135
|
return L.join("\n");
|
|
1118
1136
|
}
|
|
1119
1137
|
|
|
@@ -1142,7 +1160,7 @@ function buildRepoMd(scan) {
|
|
|
1142
1160
|
scan.tree,
|
|
1143
1161
|
"```",
|
|
1144
1162
|
"",
|
|
1145
|
-
"*When you name files in a plan, use paths from this tree. Refresh with `scriptonia init --force`.*",
|
|
1163
|
+
"*When you name files in a plan, use paths from this tree. Refresh with `npx scriptonia init --force`.*",
|
|
1146
1164
|
].filter(Boolean).join("\n");
|
|
1147
1165
|
}
|
|
1148
1166
|
|
|
@@ -1164,13 +1182,15 @@ function injectAgentsMd(cwd) {
|
|
|
1164
1182
|
This repo has a product brain (customer signal + prior decisions). Before building
|
|
1165
1183
|
any user-facing change:
|
|
1166
1184
|
|
|
1167
|
-
- \`scriptonia plan "<the issue>"\` → writes **PLAN.md** here. Execute PLAN.md
|
|
1185
|
+
- \`npx scriptonia plan "<the issue>"\` → writes **PLAN.md** here. Execute PLAN.md
|
|
1168
1186
|
exactly; respect its **Non-goals**; cite its \`sources\` ids in your PR.
|
|
1169
|
-
- \`scriptonia query "<topic>"\` → JSON: what customers asked for, contradictions,
|
|
1187
|
+
- \`npx scriptonia query "<topic>"\` → JSON: what customers asked for, contradictions,
|
|
1170
1188
|
edge cases.
|
|
1171
1189
|
- If a plan constraint is **UNRESOLVED**, stop and ask the human. If a step
|
|
1172
|
-
conflicts with the code, \`scriptonia comment plan/<slug> "<what you found>"\`
|
|
1190
|
+
conflicts with the code, \`npx scriptonia comment plan/<slug> "<what you found>"\`
|
|
1173
1191
|
and re-read PLAN.md.
|
|
1192
|
+
- After implementation, run \`npx scriptonia verify\`. Fix every BLOCK before
|
|
1193
|
+
merge; keep the generated \`.scriptonia/evals/*.yaml\` regression case.
|
|
1174
1194
|
${END}`;
|
|
1175
1195
|
|
|
1176
1196
|
let existing = "";
|
package/bin/verify.mjs
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const RANK = { PASS: 0, WARN: 1, BLOCK: 2 };
|
|
7
|
+
const CHECK_SCRIPTS = ["test", "build", "lint", "typecheck", "type-check"];
|
|
8
|
+
|
|
9
|
+
export async function runVerification(args, { cfg, cwd = process.cwd(), fetchImpl = fetch } = {}) {
|
|
10
|
+
if (!cfg?.url || !cfg?.token) throw new Error("run `npx scriptonia login` first");
|
|
11
|
+
const flags = parseFlags(args);
|
|
12
|
+
const planPath = path.resolve(cwd, flags.plan ?? "PLAN.md");
|
|
13
|
+
if (!fs.existsSync(planPath)) throw new Error(`PLAN.md not found at ${planPath} (use --plan <file>)`);
|
|
14
|
+
|
|
15
|
+
const plan = fs.readFileSync(planPath, "utf8");
|
|
16
|
+
const meta = parsePlanMetadata(plan, planPath);
|
|
17
|
+
const { diff, files, source } = collectDiff(cwd, flags);
|
|
18
|
+
if (!diff.trim()) throw new Error("no git diff to verify (use --base <ref> or --diff <file>)");
|
|
19
|
+
|
|
20
|
+
const diffForJudge = diff.length > 290_000
|
|
21
|
+
? `${diff.slice(0, 290_000)}\n\n[diff truncated by CLI; verdicts requiring omitted evidence must WARN]`
|
|
22
|
+
: diff;
|
|
23
|
+
const semanticPromise = fetchImpl(`${cfg.url}/api/verify`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ plan, diff: diffForJudge }),
|
|
27
|
+
});
|
|
28
|
+
const repositoryChecks = flags["skip-checks"] ? skippedRepositoryGate() : runRepositoryChecks(cwd, files);
|
|
29
|
+
const unresolvedConstraints = buildUnresolvedGate(plan);
|
|
30
|
+
const testCoverage = buildTestCoverageGate(files);
|
|
31
|
+
|
|
32
|
+
const response = await semanticPromise;
|
|
33
|
+
const semantic = await response.json().catch(() => ({}));
|
|
34
|
+
if (!response.ok) throw new Error(`verification service failed (${response.status}): ${JSON.stringify(semantic).slice(0, 240)}`);
|
|
35
|
+
for (const name of ["acceptance_criteria", "non_goals", "product_decisions"]) {
|
|
36
|
+
const result = semantic[name];
|
|
37
|
+
if (!result || !(result.verdict in RANK) || !Array.isArray(result.checks)) {
|
|
38
|
+
throw new Error(`verification service returned an invalid ${name} gate`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const gates = {
|
|
43
|
+
acceptance_criteria: semantic.acceptance_criteria,
|
|
44
|
+
non_goals: semantic.non_goals,
|
|
45
|
+
product_decisions: semantic.product_decisions,
|
|
46
|
+
unresolved_constraints: unresolvedConstraints,
|
|
47
|
+
repository_checks: repositoryChecks,
|
|
48
|
+
test_coverage: testCoverage,
|
|
49
|
+
};
|
|
50
|
+
const verdict = worstVerdict(Object.values(gates).map((gate) => gate.verdict));
|
|
51
|
+
const evalPath = path.resolve(cwd, flags.out ?? path.join(".scriptonia", "evals", `${meta.slug}.yaml`));
|
|
52
|
+
fs.mkdirSync(path.dirname(evalPath), { recursive: true });
|
|
53
|
+
fs.writeFileSync(evalPath, renderEvalYaml({
|
|
54
|
+
id: meta.slug,
|
|
55
|
+
plan: path.relative(cwd, planPath) || path.basename(planPath),
|
|
56
|
+
planVersion: meta.version,
|
|
57
|
+
diffSource: source,
|
|
58
|
+
diffSha256: createHash("sha256").update(diff).digest("hex"),
|
|
59
|
+
verdict,
|
|
60
|
+
gates,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
printResult({ verdict, gates, evalPath, cwd });
|
|
64
|
+
return { verdict, exitCode: verdict === "BLOCK" ? 1 : 0, evalPath, gates };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parsePlanMetadata(plan, planPath = "PLAN.md") {
|
|
68
|
+
const frontmatter = plan.match(/^---\s*\n([\s\S]*?)\n---/m)?.[1] ?? "";
|
|
69
|
+
const value = (key) => frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim().replace(/^['"]|['"]$/g, "");
|
|
70
|
+
const slug = value("plan") || slugify(plan.match(/^#\s+(.+)$/m)?.[1] || path.basename(planPath, path.extname(planPath)));
|
|
71
|
+
return { slug: slugify(slug), version: Number(value("version") || 1) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildUnresolvedGate(plan) {
|
|
75
|
+
const matches = plan.split("\n").filter((line) => /(?:⚠\s*)?UNRESOLVED/i.test(line));
|
|
76
|
+
const blocked = matches.length > 0;
|
|
77
|
+
return gate(blocked ? "BLOCK" : "PASS", {
|
|
78
|
+
evidence: blocked ? matches.slice(0, 6) : ["No UNRESOLVED marker appears in PLAN.md."],
|
|
79
|
+
plan_criterion: "Every unresolved constraint requires human approval before implementation.",
|
|
80
|
+
citation: decisionCitation(matches.join(" ")),
|
|
81
|
+
reason: blocked ? "PLAN.md still contains an unresolved approval gate." : "No unresolved approval gate remains in the approved plan.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function buildTestCoverageGate(files) {
|
|
86
|
+
const tests = files.filter(isTestFile);
|
|
87
|
+
const behavior = files.filter((file) => isSourceFile(file) && !isTestFile(file));
|
|
88
|
+
if (tests.length) return gate("PASS", {
|
|
89
|
+
evidence: tests,
|
|
90
|
+
plan_criterion: "Add or update tests for changed product behavior.",
|
|
91
|
+
citation: "PLAN.md test plan",
|
|
92
|
+
reason: "The diff includes test changes that a reviewer can inspect.",
|
|
93
|
+
});
|
|
94
|
+
if (behavior.length) return gate("BLOCK", {
|
|
95
|
+
evidence: behavior.slice(0, 8),
|
|
96
|
+
plan_criterion: "Add or update tests for changed product behavior.",
|
|
97
|
+
citation: "PLAN.md test plan",
|
|
98
|
+
reason: "Product code changed without any test file added or updated.",
|
|
99
|
+
});
|
|
100
|
+
return gate("PASS", {
|
|
101
|
+
evidence: files.length ? files.slice(0, 8) : ["No changed source files."],
|
|
102
|
+
plan_criterion: "Add or update tests for changed product behavior.",
|
|
103
|
+
citation: "PLAN.md test plan",
|
|
104
|
+
reason: "The diff contains no product-code change requiring test coverage.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runRepositoryChecks(cwd, files) {
|
|
109
|
+
const checks = [];
|
|
110
|
+
for (const root of relevantPackageRoots(cwd, files)) {
|
|
111
|
+
let pkg;
|
|
112
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); } catch { continue; }
|
|
113
|
+
const manager = packageManager(root);
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
for (const name of CHECK_SCRIPTS) {
|
|
116
|
+
if (!pkg.scripts?.[name] || seen.has(pkg.scripts[name])) continue;
|
|
117
|
+
seen.add(pkg.scripts[name]);
|
|
118
|
+
const command = manager === "yarn" ? ["yarn", name] : manager === "pnpm" ? ["pnpm", "run", name] : manager === "bun" ? ["bun", "run", name] : ["npm", "run", name];
|
|
119
|
+
const result = spawnSync(command[0], command.slice(1), { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 2_000_000, env: { ...process.env, CI: "1" } });
|
|
120
|
+
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
121
|
+
checks.push({ name, command: command.join(" "), root: path.relative(cwd, root) || ".", ok: result.status === 0, output: output.slice(-1200) || `(exit ${result.status ?? "unknown"})` });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!checks.length) return gate("WARN", {
|
|
125
|
+
evidence: ["No test, build, lint, or type-check script was found for the changed packages."],
|
|
126
|
+
plan_criterion: "Run the repository's existing quality checks.",
|
|
127
|
+
citation: "repository package scripts",
|
|
128
|
+
reason: "There were no recognized repository checks to run automatically.",
|
|
129
|
+
});
|
|
130
|
+
const failed = checks.filter((check) => !check.ok);
|
|
131
|
+
return {
|
|
132
|
+
verdict: failed.length ? "BLOCK" : "PASS",
|
|
133
|
+
checks: checks.map((check) => ({
|
|
134
|
+
verdict: check.ok ? "PASS" : "BLOCK",
|
|
135
|
+
evidence: [`${check.root}: ${check.command}`, check.output],
|
|
136
|
+
plan_criterion: "Run the repository's existing quality checks.",
|
|
137
|
+
citation: "repository package scripts",
|
|
138
|
+
reason: check.ok ? `${check.name} completed successfully.` : `${check.name} failed and must be fixed before merge.`,
|
|
139
|
+
})),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function skippedRepositoryGate() {
|
|
144
|
+
return gate("WARN", {
|
|
145
|
+
evidence: ["Repository checks skipped with --skip-checks."],
|
|
146
|
+
plan_criterion: "Run the repository's existing quality checks.",
|
|
147
|
+
citation: "CLI flag",
|
|
148
|
+
reason: "CI should run the repository checks separately before merge.",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function relevantPackageRoots(cwd, files) {
|
|
153
|
+
const candidates = [];
|
|
154
|
+
if (fs.existsSync(path.join(cwd, "package.json"))) candidates.push(cwd);
|
|
155
|
+
for (const entry of fs.readdirSync(cwd, { withFileTypes: true })) {
|
|
156
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && fs.existsSync(path.join(cwd, entry.name, "package.json"))) candidates.push(path.join(cwd, entry.name));
|
|
157
|
+
}
|
|
158
|
+
const relevant = candidates.filter((root) => {
|
|
159
|
+
const prefix = path.relative(cwd, root);
|
|
160
|
+
return !prefix || files.some((file) => file === prefix || file.startsWith(prefix + "/"));
|
|
161
|
+
});
|
|
162
|
+
return relevant.length ? relevant : candidates.length === 1 ? candidates : [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function packageManager(root) {
|
|
166
|
+
if (fs.existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
167
|
+
if (fs.existsSync(path.join(root, "yarn.lock"))) return "yarn";
|
|
168
|
+
if (fs.existsSync(path.join(root, "bun.lockb")) || fs.existsSync(path.join(root, "bun.lock"))) return "bun";
|
|
169
|
+
return "npm";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function collectDiff(cwd, flags) {
|
|
173
|
+
if (flags.diff) {
|
|
174
|
+
const file = path.resolve(cwd, flags.diff);
|
|
175
|
+
const diff = fs.readFileSync(file, "utf8");
|
|
176
|
+
return { diff, files: diffFiles(diff), source: `file:${path.relative(cwd, file)}` };
|
|
177
|
+
}
|
|
178
|
+
const base = flags.base || (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : null);
|
|
179
|
+
const range = base ? `${base}...HEAD` : "HEAD";
|
|
180
|
+
const result = spawnSync("git", ["diff", range, "--no-ext-diff", "--unified=40"], { cwd, encoding: "utf8", maxBuffer: 20_000_000 });
|
|
181
|
+
if (result.status !== 0) throw new Error(`could not read git diff for ${range}: ${(result.stderr || "").trim()}`);
|
|
182
|
+
let diff = result.stdout || "";
|
|
183
|
+
if (!base) diff += untrackedDiff(cwd);
|
|
184
|
+
return { diff, files: diffFiles(diff), source: `git:${range}` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function untrackedDiff(cwd) {
|
|
188
|
+
const status = spawnSync("git", ["status", "--porcelain", "--untracked-files=all"], { cwd, encoding: "utf8" });
|
|
189
|
+
if (status.status !== 0) return "";
|
|
190
|
+
const files = status.stdout.split("\n").filter((line) => line.startsWith("?? ")).map((line) => line.slice(3));
|
|
191
|
+
const chunks = [];
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
const full = path.join(cwd, file);
|
|
194
|
+
try {
|
|
195
|
+
const stat = fs.statSync(full);
|
|
196
|
+
if (!stat.isFile() || stat.size > 500_000) continue;
|
|
197
|
+
const body = fs.readFileSync(full, "utf8");
|
|
198
|
+
chunks.push(`\ndiff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${body.split("\n").length} @@\n${body.split("\n").map((line) => `+${line}`).join("\n")}\n`);
|
|
199
|
+
} catch { /* ignore unreadable/binary untracked files */ }
|
|
200
|
+
}
|
|
201
|
+
return chunks.join("");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function diffFiles(diff) {
|
|
205
|
+
return [...new Set([...diff.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gm)].map((match) => match[2]))];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isTestFile(file) {
|
|
209
|
+
return /(^|\/)(?:__tests__|tests?|spec)(?:\/|$)|\.(?:test|spec)\.[^.]+$/i.test(file);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isSourceFile(file) {
|
|
213
|
+
return /\.(?:[cm]?[jt]sx?|py|go|rs|java|rb|php|swift|kt)$/i.test(file) && !/(?:^|\/)(?:scripts?|migrations?|drizzle)(?:\/|$)/i.test(file);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function gate(verdict, check) {
|
|
217
|
+
return { verdict, checks: [{ verdict, ...check }] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function worstVerdict(verdicts) {
|
|
221
|
+
return verdicts.reduce((worst, verdict) => (RANK[verdict] > RANK[worst] ? verdict : worst), "PASS");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decisionCitation(text) {
|
|
225
|
+
return text.match(/(?:decision|dec)[-_:/ ]?[a-z0-9_-]+/i)?.[0] || "PLAN.md constraint";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderEvalYaml(data) {
|
|
229
|
+
const q = (value) => JSON.stringify(String(value ?? ""));
|
|
230
|
+
const lines = ["version: 1", `id: ${q(data.id)}`, `plan: ${q(data.plan)}`, `plan_version: ${data.planVersion}`, `diff_source: ${q(data.diffSource)}`, `diff_sha256: ${q(data.diffSha256)}`, `generated_at: ${q(new Date().toISOString())}`, `verdict: ${data.verdict}`, "gates:"];
|
|
231
|
+
for (const [name, gateResult] of Object.entries(data.gates)) {
|
|
232
|
+
lines.push(` ${name}:`, ` verdict: ${gateResult.verdict}`, " checks:");
|
|
233
|
+
for (const check of gateResult.checks ?? []) {
|
|
234
|
+
lines.push(` - verdict: ${check.verdict}`, ` plan_criterion: ${q(check.plan_criterion)}`, ` citation: ${q(check.citation)}`, ` reason: ${q(check.reason)}`, " evidence:");
|
|
235
|
+
for (const evidence of check.evidence ?? []) lines.push(` - ${q(String(evidence).slice(0, 1600))}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return lines.join("\n") + "\n";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function printResult({ verdict, gates, evalPath, cwd }) {
|
|
242
|
+
const icon = verdict === "PASS" ? "✓" : verdict === "WARN" ? "!" : "✕";
|
|
243
|
+
console.log(`\n ${icon} SCRIPTONIA VERIFY · ${verdict}\n`);
|
|
244
|
+
for (const [name, result] of Object.entries(gates)) {
|
|
245
|
+
console.log(` ${result.verdict.padEnd(5)} ${name.replaceAll("_", " ")}`);
|
|
246
|
+
for (const check of (result.checks ?? []).filter((item) => item.verdict !== "PASS").slice(0, 3)) console.log(` ${check.reason}`);
|
|
247
|
+
}
|
|
248
|
+
console.log(`\n case ${path.relative(cwd, evalPath)}`);
|
|
249
|
+
console.log(` exit ${verdict === "BLOCK" ? 1 : 0}\n`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseFlags(args) {
|
|
253
|
+
const out = {};
|
|
254
|
+
for (let i = 0; i < args.length; i++) {
|
|
255
|
+
if (!args[i].startsWith("--")) continue;
|
|
256
|
+
const [name, inline] = args[i].slice(2).split(/=(.*)/s);
|
|
257
|
+
if (inline !== undefined) out[name] = inline;
|
|
258
|
+
else if (args[i + 1] && !args[i + 1].startsWith("--")) out[name] = args[++i];
|
|
259
|
+
else out[name] = true;
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function slugify(value) {
|
|
265
|
+
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 80) || "plan";
|
|
266
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scriptonia",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Turn customer signal into
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Turn customer signal into plans and merge-blocking verification for AI-written PRs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"scriptonia": "bin/scriptonia.mjs"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"bin",
|
|
11
14
|
"README.md"
|