scriptonia 0.6.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 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 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**. |
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. Confidential signal stays
58
- in your home dir only `PLAN.md` and the `AGENTS.md` note land in the repo.
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+.
@@ -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,9 +31,9 @@ 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
- status, logout, skill: reinstallSkill, mcp,
36
+ status, credits, logout, skill: reinstallSkill, mcp,
36
37
  flow, guide: flow,
37
38
  help: async () => usage(),
38
39
  };
@@ -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,26 +128,33 @@ 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 : ""}`);
134
135
 
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>"`);
136
+ const cr = o?.credits;
137
+ const creditStr = cr && cr.metered !== false ? ` · ${cr.plan} ${cr.used}/${cr.cap} credits` : "";
138
+ console.log(` ${C.dim}THE LOOP${C.r} ${C.dim}${s.total} signals · ${plansN} plans${creditStr}${C.r}`);
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>"`);
138
141
  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> "…"`);
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`);
140
144
 
141
- console.log(`\n ${C.b}NEXT${C.r} ${nextStep(s, plansN, recentPlan, repo, C)}\n`);
145
+ console.log(`\n ${C.b}NEXT${C.r} ${nextStep(s, plansN, recentPlan, repo, C)}`);
146
+ if (cr && cr.metered !== false && cr.available <= 0) {
147
+ console.log(` ${C.w}Out of credits${C.r} ${C.dim}— ${cr.topup}${C.r}`);
148
+ }
149
+ console.log("");
142
150
  }
143
151
 
144
152
  function nextStep(s, plansN, recentPlan, repo, C) {
145
153
  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}`;
154
+ return `${C.g}npx scriptonia add feedback.txt call.vtt${C.r} ${C.dim}— feed the brain customer signal${C.r}`;
147
155
  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}`;
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}`;
150
158
  }
151
159
 
152
160
  // ─────────────────────────────────────────────────────────────
@@ -158,11 +166,11 @@ async function add(args) {
158
166
  const items = args.filter((a) => !a.startsWith("--"));
159
167
  if (items.length === 0) {
160
168
  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' +
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' +
163
171
  " flags: --source slack|ticket|sales_call|voice_note|founder_note|email|notion|github\n" +
164
172
  " --segment enterprise|mid_market|smb|free\n" +
165
- " pipe: cat thread.txt | scriptonia add -",
173
+ " pipe: cat thread.txt | npx scriptonia add -",
166
174
  );
167
175
  }
168
176
 
@@ -246,12 +254,12 @@ async function add(args) {
246
254
  if (cfg.inited) appendLog(cfg.dir, "add", `context.md synthesized (${(q.sources ?? []).length} cited, ${unresolved} contradictions)`);
247
255
  }
248
256
  } 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`);
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`);
250
258
  return;
251
259
  }
252
260
 
253
261
  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`);
262
+ console.log(` ${C.dim}The brain is enriching them now. Then:${C.r} ${C.g}npx scriptonia plan "<issue>"${C.r}\n`);
255
263
  }
256
264
  }
257
265
 
@@ -274,7 +282,7 @@ async function contexts() {
274
282
 
275
283
  if (o.contexts_count === 0) {
276
284
  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`);
285
+ if ((o.signals?.total ?? 0) === 0) console.log(` Add signal first: ${C.g}npx scriptonia add <files>${C.r}\n`);
278
286
  else console.log(` The brain is still indexing ${o.signals.total} signals — check back in a minute.\n`);
279
287
  return;
280
288
  }
@@ -285,7 +293,7 @@ async function contexts() {
285
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}`);
286
294
  console.log(` ${C.v}${cfg.url}/contexts/${c.slug}${C.r}`);
287
295
  }
288
- 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`);
289
297
  }
290
298
 
291
299
  // ─────────────────────────────────────────────────────────────
@@ -299,14 +307,14 @@ async function plans() {
299
307
  const o = await res.json();
300
308
  const list = o.plans ?? [];
301
309
  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`);
310
+ console.log(`\n ${C.dim}No plans yet.${C.r} Generate one: ${C.g}npx scriptonia plan "<issue>"${C.r}\n`);
303
311
  return;
304
312
  }
305
313
  console.log(`\n ${C.b}${list.length} plan${list.length > 1 ? "s" : ""}${C.r} ${C.dim}· ${o.project}${C.r}\n`);
306
314
  for (const p of list) {
307
315
  console.log(` ${C.b}${p.slug}${C.r} ${C.dim}v${p.version}${C.r}`);
308
316
  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}`);
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}`);
310
318
  }
311
319
  console.log("");
312
320
  }
@@ -323,8 +331,8 @@ async function comment(args) {
323
331
  const text = positional.slice(1).join(" ").trim();
324
332
  if (!target || !text) {
325
333
  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' +
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' +
328
336
  ' --as "Name" attribute the comment (defaults to you)',
329
337
  );
330
338
  }
@@ -339,7 +347,8 @@ async function comment(args) {
339
347
  body: JSON.stringify({ slug, body: text, author: flags.as ?? cfg.email }),
340
348
  });
341
349
  const data = await res.json().catch(() => ({}));
342
- if (res.status === 404) fail(`no plan "${slug}" — generate one first: scriptonia plan "<issue>"`);
350
+ if (res.status === 402) failCredits(data);
351
+ if (res.status === 404) fail(`no plan "${slug}" — generate one first: npx scriptonia plan "<issue>"`);
343
352
  if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data).slice(0, 200)}`);
344
353
  // Approval-chain receipt: which human note produced which plan version.
345
354
  if (cfg.inited) {
@@ -362,11 +371,12 @@ async function comment(args) {
362
371
  body: JSON.stringify({ slug: target, body: text, author: flags.as ?? cfg.email }),
363
372
  });
364
373
  const data = await res.json().catch(() => ({}));
365
- if (res.status === 404) fail(`no context "${target}" — see \`scriptonia contexts\``);
374
+ if (res.status === 402) failCredits(data);
375
+ if (res.status === 404) fail(`no context "${target}" — see \`npx scriptonia contexts\``);
366
376
  if (!res.ok) fail(`comment failed (${res.status}): ${JSON.stringify(data)}`);
367
377
 
368
378
  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`);
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`);
370
380
  }
371
381
 
372
382
  // ─────────────────────────────────────────────────────────────
@@ -376,7 +386,7 @@ async function query(args) {
376
386
  const cfg = activeCreds();
377
387
  const flags = parseFlags(args);
378
388
  const q = args.filter((a) => !a.startsWith("--")).join(" ").trim();
379
- 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]');
380
390
 
381
391
  const res = await fetch(`${cfg.url}/api/query`, {
382
392
  method: "POST",
@@ -384,6 +394,7 @@ async function query(args) {
384
394
  body: JSON.stringify({ query: q, window: flags.window, segment: flags.segment }),
385
395
  });
386
396
  const data = await res.json();
397
+ if (res.status === 402) failCredits(data);
387
398
  if (!res.ok) fail(`query failed (${res.status}): ${JSON.stringify(data)}`);
388
399
 
389
400
  const wantJson = flags.json !== undefined || !process.stdout.isTTY;
@@ -411,13 +422,66 @@ async function link(args) {
411
422
  // ─────────────────────────────────────────────────────────────
412
423
  async function status() {
413
424
  const cfg = loadConfig();
414
- if (!cfg) { console.log("not logged in — run `scriptonia login`"); return; }
425
+ if (!cfg) { console.log("not logged in — run `npx scriptonia login`"); return; }
426
+ const active = activeCreds();
427
+ const C = colors();
415
428
  console.log(`signed in as ${cfg.email}`);
416
- console.log(`project ${cfg.project}`);
429
+ console.log(`project ${active.project}`);
430
+ const c = await fetchCredits(active);
431
+ if (c && c.metered !== false) {
432
+ const bar = creditBar(c.used, c.cap, C);
433
+ console.log(`plan ${C.b}${c.plan}${C.r}`);
434
+ console.log(`credits ${bar} ${c.used}/${c.cap} used · resets ${c.resets_at}`);
435
+ if (c.available <= 0) console.log(` ${C.w}out of credits${C.r} — ${c.topup}`);
436
+ else if (c.available <= Math.max(2, Math.round(c.cap * 0.2))) console.log(` ${C.dim}running low — ${c.topup}${C.r}`);
437
+ }
417
438
  console.log(`endpoint ${cfg.url}`);
418
439
  console.log(`config ${CONFIG_PATH}`);
419
440
  }
420
441
 
442
+ // ── credits ───────────────────────────────────────────────────
443
+ async function credits() {
444
+ const cfg = activeCreds();
445
+ const C = colors();
446
+ const c = await fetchCredits(cfg);
447
+ if (!c) fail("could not load credits");
448
+ if (c.metered === false) { console.log(`\n ${C.dim}This project isn't metered.${C.r}\n`); return; }
449
+ console.log(`\n ${C.b}${c.plan.toUpperCase()}${C.r} · ${cfg.project}`);
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}`);
451
+ console.log(`\n ${C.dim}Plans, comments & verification cost 5 · queries 1 · adding signal is free.${C.r}`);
452
+ if (c.available <= 0) console.log(` ${C.w}Out of credits.${C.r} ${c.topup}`);
453
+ else console.log(` ${C.dim}Need more? ${c.topup}${C.r}`);
454
+ console.log("");
455
+ }
456
+
457
+ async function fetchCredits(cfg) {
458
+ try {
459
+ const res = await fetch(`${cfg.url}/api/credits`, { headers: { authorization: `Bearer ${cfg.token}` } });
460
+ if (!res.ok) return null;
461
+ return await res.json();
462
+ } catch { return null; }
463
+ }
464
+
465
+ function creditBar(used, cap, C) {
466
+ const width = 16;
467
+ const frac = cap > 0 ? Math.min(1, used / cap) : 0;
468
+ const filled = Math.round(frac * width);
469
+ const color = frac >= 1 ? C.w : frac >= 0.8 ? C.w : C.g;
470
+ return `${color}${"█".repeat(filled)}${C.dim}${"░".repeat(width - filled)}${C.r}`;
471
+ }
472
+
473
+ /** Render an HTTP 402 credits_exhausted body and exit. Shared by plan/query/comment. */
474
+ function failCredits(data) {
475
+ const C = colors();
476
+ const resets = data.resets_at ? ` (resets ${data.resets_at})` : "";
477
+ console.error(`\n ${C.w}✕ Out of credits${C.r} — ${data.plan}: ${data.used}/${data.cap} used this month${resets}.\n`);
478
+ console.error(` Your signal is safe and still being collected. Plans & queries resume`);
479
+ console.error(` when credits do.\n`);
480
+ console.error(` ${C.b}Top up:${C.r} ${data.topup}`);
481
+ console.error(` ${C.dim}(adding signal, sync, contexts, plans list — all still free)${C.r}\n`);
482
+ process.exit(1);
483
+ }
484
+
421
485
  async function logout() {
422
486
  if (fs.existsSync(CONFIG_PATH)) fs.rmSync(CONFIG_PATH);
423
487
  console.log("logged out.");
@@ -443,7 +507,7 @@ function skillMarkdown() {
443
507
  name: scriptonia
444
508
  description: >
445
509
  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
510
+ UX impact — run \`npx scriptonia plan "<the issue>"\`. It writes PLAN.md here: a
447
511
  hyper-focused, sourced, executable plan grounded in this project's real
448
512
  customer signal, prior decisions, and human comments. Execute PLAN.md exactly.
449
513
  ---
@@ -452,18 +516,20 @@ description: >
452
516
 
453
517
  When asked to build/fix anything user-facing:
454
518
 
455
- 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.
456
520
  2. Execute PLAN.md **exactly**. Respect its **Non-goals** — do not add anything
457
521
  outside the acceptance criteria.
458
522
  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.
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.
460
526
 
461
527
  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.
528
+ \`npx scriptonia comment plan/<slug> "<what you found>"\` and re-read the regenerated PLAN.md.
463
529
 
464
- 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>"\`
465
531
  (JSON: requirements with source_ids, contradictions, edge_cases). Quote a
466
- customer verbatim with \`scriptonia link "<signal_id>"\`.
532
+ customer verbatim with \`npx scriptonia link "<signal_id>"\`.
467
533
 
468
534
  Rule: never invent demand. If nothing in the plan/query supports a step, it
469
535
  doesn't belong in the PR.
@@ -526,7 +592,7 @@ function loadConfig() {
526
592
  }
527
593
  function requireConfig() {
528
594
  const cfg = loadConfig();
529
- if (!cfg) fail("not logged in — run `scriptonia login`");
595
+ if (!cfg) fail("not logged in — run `npx scriptonia login`");
530
596
  return cfg;
531
597
  }
532
598
  function saveConfig(cfg) {
@@ -600,29 +666,37 @@ function usage() {
600
666
  console.log(`scriptonia — issue → executable plan for your coding agent
601
667
 
602
668
  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
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
605
671
  3 BUILD claude "execute PLAN.md" your agent ships it → PR
606
- 4 REFINE scriptonia comment plan/<slug> "…" a note regenerates the plan
672
+ 4 REFINE npx scriptonia comment plan/<slug> "…" a note regenerates the plan
607
673
 
608
674
  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
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
611
677
 
612
678
  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
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
617
684
 
618
685
  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
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.
626
700
  `);
627
701
  }
628
702
 
@@ -633,7 +707,7 @@ function usage() {
633
707
  /** Repo-local project when pwd is inside an initialized repo, else the global login. */
634
708
  function activeCreds() {
635
709
  const g = loadConfig();
636
- if (!g) fail("run `scriptonia login` first");
710
+ if (!g) fail("run `npx scriptonia login` first");
637
711
  const proj = findProjectForCwd();
638
712
  if (proj) {
639
713
  // v0.6 brains are flat (REPO.md etc. at the project dir root); older ones
@@ -672,7 +746,7 @@ function findProjectForCwd() {
672
746
  async function init(args) {
673
747
  const flags = parseFlags(args);
674
748
  const g = loadConfig();
675
- 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");
676
750
  const C = colors();
677
751
  const cwd = process.cwd();
678
752
 
@@ -680,8 +754,8 @@ async function init(args) {
680
754
  if (existing && !flags.force) {
681
755
  writeRepoMd(existing.dir, scanRepo(cwd));
682
756
  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`);
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`);
685
759
  return;
686
760
  }
687
761
 
@@ -701,7 +775,7 @@ async function init(args) {
701
775
  headers: { authorization: `Bearer ${g.token}`, "content-type": "application/json" },
702
776
  body: JSON.stringify({ name }),
703
777
  });
704
- 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\``);
705
779
  const data = await res.json();
706
780
  projectId = data.projectId; token = data.token;
707
781
  }
@@ -736,15 +810,15 @@ async function init(args) {
736
810
  console.log(` ${C.g}✓${C.r} Scanned repo (${scan.stack})`);
737
811
  console.log(` ${C.g}✓${C.r} Wrote agent instructions ${C.dim}→ ./AGENTS.md${C.r}`);
738
812
  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`);
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`);
741
815
  }
742
816
 
743
817
  // ── sync ──────────────────────────────────────────────────────
744
818
  async function sync() {
745
819
  const cfg = activeCreds();
746
820
  const C = colors();
747
- if (!cfg.inited) fail("run `scriptonia init` in this repo first");
821
+ if (!cfg.inited) fail("run `npx scriptonia init` in this repo first");
748
822
  const res = await fetch(`${cfg.url}/api/brain`, { headers: { authorization: `Bearer ${cfg.token}` } });
749
823
  if (!res.ok) fail(`sync failed (${res.status})`);
750
824
  const brain = await res.json();
@@ -785,7 +859,7 @@ async function plan(args) {
785
859
  }
786
860
 
787
861
  const issue = args.filter((a) => !a.startsWith("--")).join(" ").trim();
788
- 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>]');
789
863
 
790
864
  const repoSummary = cfg.inited && fs.existsSync(path.join(cfg.brainDir, "REPO.md"))
791
865
  ? fs.readFileSync(path.join(cfg.brainDir, "REPO.md"), "utf8").slice(0, 6000)
@@ -798,10 +872,17 @@ async function plan(args) {
798
872
  body: JSON.stringify({ issue, repo_summary: repoSummary }),
799
873
  });
800
874
  const data = await res.json();
875
+ if (res.status === 402) failCredits(data);
801
876
  if (!res.ok) fail(`plan failed (${res.status}): ${JSON.stringify(data).slice(0, 200)}`);
802
877
  writePlan(cfg, data, flags);
803
878
  }
804
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
+
805
886
  function writePlan(cfg, data, flags) {
806
887
  const C = colors();
807
888
  const out = flags.out ?? "PLAN.md";
@@ -817,7 +898,13 @@ function writePlan(cfg, data, flags) {
817
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}`);
818
899
  console.log(` ${C.dim}${(data.sources ?? []).length} cited signal(s)${data.contradictions ? `, ${data.contradictions} unresolved contradiction(s)` : ""}${C.r}`);
819
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}`);
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`);
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}`);
902
+ if (data.credits) {
903
+ const cr = data.credits;
904
+ const low = cr.cap > 0 && cr.cap - cr.used <= Math.max(2, Math.round(cr.cap * 0.2));
905
+ console.log(` ${low ? C.w : C.dim}${cr.used}/${cr.cap} credits used this month${low ? " — top up: sathwik07@scriptonia.dev" : ""}${C.r}`);
906
+ }
907
+ console.log("");
821
908
  }
822
909
 
823
910
  // ── repo scan ─────────────────────────────────────────────────
@@ -902,7 +989,7 @@ function writeProjectMd(dir, scan, pitch) {
902
989
  "",
903
990
  `# ${scan.name}`,
904
991
  "",
905
- 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`)"),
906
993
  "",
907
994
  `**Stack:** ${scan.stack}`,
908
995
  scan.deps.length ? `**Key deps:** ${scan.deps.join(", ")}` : "",
@@ -911,7 +998,7 @@ function writeProjectMd(dir, scan, pitch) {
911
998
  scan.readmeHead ? "## From the README\n\n" + scan.readmeHead : "",
912
999
  "",
913
1000
  "---",
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`.*",
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`.*",
915
1002
  ].filter((l) => l !== "" && l !== null).join("\n");
916
1003
  fs.writeFileSync(path.join(dir, "PROJECT.md"), md);
917
1004
  }
@@ -938,25 +1025,27 @@ function writeAgentsBrainMd(dir, meta, decisions) {
938
1025
  "",
939
1026
  "## 1. Identity",
940
1027
  "",
941
- 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)",
942
1029
  meta.stack ? `\nStack: ${meta.stack}` : "",
943
1030
  "",
944
1031
  "## 2. Workflow — non-negotiable",
945
1032
  "",
946
- '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>"`.',
947
1034
  " It writes PLAN.md, grounded in real customer signal and checked against",
948
1035
  " the rules below.",
949
1036
  "2. Execute PLAN.md **exactly**. Its **Non-goals** section is a fence — do not",
950
1037
  " build past it.",
951
1038
  "3. Any constraint marked **⚠ UNRESOLVED** is an approval gate: STOP and get a",
952
- ' human comment (`scriptonia comment plan/<slug> "…"`) before writing code.',
1039
+ ' human comment (`npx scriptonia comment plan/<slug> "…"`) before writing code.',
953
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.",
954
1043
  "",
955
1044
  "## 3. Non-negotiable constraints (from the decisions corpus)",
956
1045
  "",
957
1046
  ];
958
1047
  if (decisions.length === 0) {
959
- 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._");
960
1049
  } else {
961
1050
  for (const d of decisions) {
962
1051
  L.push(`### ${d.title}${d.ref ? ` (${d.ref})` : ""}`, "", d.body, "");
@@ -987,7 +1076,7 @@ function renderRule(d) {
987
1076
  "check: |",
988
1077
  ...String(d.body).split("\n").map((l) => ` ${l}`),
989
1078
  "enforcement: |",
990
- " Server-side during `scriptonia plan`: any issue conflicting with this rule",
1079
+ " Server-side during `npx scriptonia plan`: any issue conflicting with this rule",
991
1080
  " is marked ⚠ UNRESOLVED in PLAN.md until a human comment approves the",
992
1081
  " override (recorded in comments/plan/<slug>/).",
993
1082
  "---",
@@ -1027,7 +1116,7 @@ function renderContextLive(issue, q, project, signalId) {
1027
1116
  if (cons.length) {
1028
1117
  L.push("## ⚠ CONTRADICTION GATE — UNRESOLVED");
1029
1118
  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.", "");
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.", "");
1031
1120
  } else {
1032
1121
  L.push("## Contradiction gate", "", "✓ No conflicts with prior decisions on record.", "");
1033
1122
  }
@@ -1042,7 +1131,7 @@ function renderContextLive(issue, q, project, signalId) {
1042
1131
  for (const s of q.sources ?? []) {
1043
1132
  L.push(`- \`sig_${s.signal_id.slice(0, 8)}\` ${s.source}${s.ref ? ` — ${s.ref}` : ""} _(similarity ${s.similarity})_`);
1044
1133
  }
1045
- 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.*`);
1046
1135
  return L.join("\n");
1047
1136
  }
1048
1137
 
@@ -1071,7 +1160,7 @@ function buildRepoMd(scan) {
1071
1160
  scan.tree,
1072
1161
  "```",
1073
1162
  "",
1074
- "*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`.*",
1075
1164
  ].filter(Boolean).join("\n");
1076
1165
  }
1077
1166
 
@@ -1093,13 +1182,15 @@ function injectAgentsMd(cwd) {
1093
1182
  This repo has a product brain (customer signal + prior decisions). Before building
1094
1183
  any user-facing change:
1095
1184
 
1096
- - \`scriptonia plan "<the issue>"\` → writes **PLAN.md** here. Execute PLAN.md
1185
+ - \`npx scriptonia plan "<the issue>"\` → writes **PLAN.md** here. Execute PLAN.md
1097
1186
  exactly; respect its **Non-goals**; cite its \`sources\` ids in your PR.
1098
- - \`scriptonia query "<topic>"\` → JSON: what customers asked for, contradictions,
1187
+ - \`npx scriptonia query "<topic>"\` → JSON: what customers asked for, contradictions,
1099
1188
  edge cases.
1100
1189
  - 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>"\`
1190
+ conflicts with the code, \`npx scriptonia comment plan/<slug> "<what you found>"\`
1102
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.
1103
1194
  ${END}`;
1104
1195
 
1105
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.6.0",
4
- "description": "Turn customer signal into executable plans your coding agent runs. Issue in, PLAN.md out.",
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"