@redspec/cli 0.1.0-alpha.1

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/dist/index.js ADDED
@@ -0,0 +1,1429 @@
1
+ // src/index.ts
2
+ import { Command } from "commander";
3
+
4
+ // src/commands/accept.ts
5
+ import { spawnSync } from "child_process";
6
+ import pc from "picocolors";
7
+ import { stamp, writeLock } from "@redspec/core";
8
+
9
+ // src/context.ts
10
+ import { existsSync, readFileSync } from "fs";
11
+ import { join } from "path";
12
+ import {
13
+ loadConfig,
14
+ loadSpecs,
15
+ publishedBoard,
16
+ reportBundle,
17
+ unregisteredBundles
18
+ } from "@redspec/core";
19
+ async function loadContext(root = process.cwd(), now = /* @__PURE__ */ new Date()) {
20
+ const { config, path } = await loadConfig(root);
21
+ const specs = await loadSpecs(root, config);
22
+ const reports = specs.map((s) => reportBundle(root, config, s, now));
23
+ const extra = [...unregisteredBundles(root, config, specs), ...publishedBoard(config)];
24
+ const pkgPath = join(root, "package.json");
25
+ const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, "utf8")) : null;
26
+ return { root, config, configPath: path, specs, reports, extra, pkg };
27
+ }
28
+ var allFindings = (ctx) => [
29
+ ...ctx.reports.flatMap((r) => r.findings),
30
+ ...ctx.extra
31
+ ];
32
+ function packageManager(root) {
33
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
34
+ if (existsSync(join(root, "yarn.lock"))) return "yarn";
35
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
36
+ return "bun";
37
+ return "npm";
38
+ }
39
+ var runScript = (pm, script) => pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
40
+
41
+ // src/commands/accept.ts
42
+ async function accept(root, opts) {
43
+ const ctx = await loadContext(root);
44
+ const log = opts.quiet ? () => {
45
+ } : console.log;
46
+ const targets = [];
47
+ if (opts.slice) {
48
+ const report2 = ctx.reports.find((r) => r.slices.some((s) => s.path === opts.slice));
49
+ const slice2 = report2?.slices.find((s) => s.path === opts.slice);
50
+ if (!report2 || !slice2) {
51
+ console.error(pc.red(`No slice at ${opts.slice}.`));
52
+ return 1;
53
+ }
54
+ for (const id of slice2.claims) targets.push({ id, slice: slice2.path, report: report2 });
55
+ }
56
+ for (const id of opts.ids ?? []) {
57
+ const report2 = ctx.reports.find((r) => id in r.digests);
58
+ if (!report2) {
59
+ console.error(
60
+ pc.red(`Nothing digests to "${id}". Is it declared, and spelled as declared?`)
61
+ );
62
+ return 1;
63
+ }
64
+ const owners = report2.slices.filter((s) => s.claims.includes(id));
65
+ const owner = owners[owners.length - 1];
66
+ if (!owner) {
67
+ console.error(
68
+ pc.red(
69
+ `"${id}" is claimed by no slice. Claim it first (\`redspec new slice\`), or accept via --slice.`
70
+ )
71
+ );
72
+ return 1;
73
+ }
74
+ targets.push({ id, slice: owner.path, report: report2 });
75
+ }
76
+ if (targets.length === 0) {
77
+ console.error(pc.red("Nothing to accept. Pass IDs or --slice <path>."));
78
+ return 1;
79
+ }
80
+ const command = opts.command ?? ctx.config.accept.command;
81
+ log(pc.dim(`$ ${command}`));
82
+ const run2 = spawnSync(command, {
83
+ shell: true,
84
+ stdio: opts.quiet ? "ignore" : "inherit",
85
+ cwd: root
86
+ });
87
+ if (run2.status !== 0) {
88
+ console.error(pc.red(`
89
+ Verification exited ${run2.status}. Nothing stamped.`));
90
+ return 1;
91
+ }
92
+ const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], {
93
+ cwd: root,
94
+ encoding: "utf8"
95
+ });
96
+ const sha = commit.status === 0 ? commit.stdout.trim() : void 0;
97
+ const byReport = /* @__PURE__ */ new Map();
98
+ for (const tg of targets)
99
+ byReport.set(tg.report.slug, [...byReport.get(tg.report.slug) ?? [], tg]);
100
+ for (const [slug, tgs] of byReport) {
101
+ const report2 = ctx.reports.find((r) => r.slug === slug);
102
+ let lock = report2.lock;
103
+ for (const tg of tgs) {
104
+ lock = stamp(lock, [tg.id], report2.digests, tg.slice, {
105
+ commit: sha,
106
+ note: opts.clarification
107
+ });
108
+ }
109
+ writeLock(report2.lockPath, lock);
110
+ for (const tg of tgs)
111
+ log(` ${pc.green("stamped")} ${tg.id} ${pc.dim(`\u2190 ${tg.slice}`)}`);
112
+ }
113
+ return 0;
114
+ }
115
+
116
+ // src/commands/board.ts
117
+ import { spawn } from "child_process";
118
+ import pc2 from "picocolors";
119
+ async function board(root, feature) {
120
+ const ctx = await loadContext(root);
121
+ if (ctx.config.framework === "none") {
122
+ console.error(
123
+ pc2.red(
124
+ 'No framework adapter configured, so there is no spec route to open. Set framework: "next" in spec.config.ts.'
125
+ )
126
+ );
127
+ return 1;
128
+ }
129
+ const url = `http://localhost:3000${ctx.config.route}${feature ? `/${feature}` : ""}`;
130
+ console.log(pc2.dim(`$ ${runScript(packageManager(root), "dev")}`));
131
+ console.log(`${pc2.bold("Board:")} ${url}`);
132
+ const child = spawn(runScript(packageManager(root), "dev"), {
133
+ shell: true,
134
+ stdio: "inherit",
135
+ cwd: root
136
+ });
137
+ return await new Promise((resolve) => child.on("exit", (code) => resolve(code ?? 0)));
138
+ }
139
+
140
+ // src/commands/check.ts
141
+ import pc4 from "picocolors";
142
+
143
+ // src/print.ts
144
+ import pc3 from "picocolors";
145
+ import { flowCoverage } from "@redspec/core";
146
+ var GROUPS = [
147
+ {
148
+ title: "UNNAMED \u2014 declared with only an ID",
149
+ kinds: ["unnamed-state"],
150
+ hint: "\u2192 say what the person is looking at",
151
+ tone: pc3.yellow
152
+ },
153
+ {
154
+ title: "DECLARED \u2014 not yet rendered",
155
+ kinds: ["declared-not-rendered"],
156
+ hint: "\u2192 /render-states",
157
+ tone: pc3.yellow
158
+ },
159
+ {
160
+ title: "AMENDED \u2014 changed since it was verified",
161
+ kinds: ["amended", "unverified"],
162
+ hint: "\u2192 /amend, or redspec accept",
163
+ tone: pc3.magenta
164
+ },
165
+ {
166
+ title: "WAIVERS",
167
+ kinds: ["waiver-due", "waiver-unwitnessed", "unknown-witness"],
168
+ hint: "\u2192 re-read it: believe it, or build the state",
169
+ tone: pc3.cyan
170
+ },
171
+ {
172
+ title: "DECISION TABLES",
173
+ kinds: ["table-gap", "table-overlap", "table-parse", "unknown-state-outcome"],
174
+ hint: "\u2192 make the table total",
175
+ tone: pc3.red
176
+ },
177
+ {
178
+ title: "SHAPE",
179
+ kinds: [
180
+ "off-path",
181
+ "off-checklist",
182
+ "unclaimed-id",
183
+ "unknown-surface",
184
+ "surface-mismatch",
185
+ "dangling-deviation",
186
+ "deviation-off-deviation",
187
+ "compound-actor",
188
+ "spine-ends-early",
189
+ "empty-spine",
190
+ "bad-id"
191
+ ],
192
+ hint: "\u2192 the registry disagrees with itself",
193
+ tone: pc3.red
194
+ },
195
+ {
196
+ title: "BUNDLE",
197
+ kinds: [
198
+ "actor-without-flow",
199
+ "missing-brief",
200
+ "unregistered-feature",
201
+ "unknown-copy"
202
+ ],
203
+ hint: "\u2192 the bundle on disk disagrees with the registry",
204
+ tone: pc3.red
205
+ },
206
+ {
207
+ title: "CONFIG",
208
+ kinds: ["board-published"],
209
+ hint: "\u2192 the repo disagrees with its own declarations",
210
+ tone: pc3.red
211
+ },
212
+ {
213
+ title: "COVERAGE",
214
+ kinds: ["orphan", "claimless", "unknown-id", "claimed-twice"],
215
+ hint: "\u2192 /cut-slices",
216
+ tone: pc3.red
217
+ }
218
+ ];
219
+ function printStatus(ctx, write = console.log) {
220
+ if (ctx.specs.length === 0 && ctx.extra.length === 0) {
221
+ write(pc3.dim("No specs yet. Run /draft-skeleton, or `redspec new feature <slug>`."));
222
+ return;
223
+ }
224
+ for (const report2 of ctx.reports) {
225
+ const spec = ctx.specs.find((s) => s.spec.slug === report2.slug).spec;
226
+ const declared = report2.artifacts.filter((a) => a.kind === "STATE").length;
227
+ const rendered = Object.keys(spec.cases).length;
228
+ write(
229
+ `${pc3.bold(report2.slug)}${" ".repeat(Math.max(1, 50 - report2.slug.length))}${pc3.dim(`${rendered} of ${declared} states rendered`)}`
230
+ );
231
+ printGroups(report2.findings, write);
232
+ const cov = flowCoverage(spec, ctx.config.journeyBudget);
233
+ const paths = cov.reduce((n, c) => n + c.reachablePaths, 0);
234
+ const stamped = Object.keys(report2.lock.entries).length;
235
+ const line = [
236
+ `${report2.findings.length} finding${report2.findings.length === 1 ? "" : "s"}`,
237
+ `${cov.length} flow${cov.length === 1 ? "" : "s"} \xB7 ${paths} reachable path${paths === 1 ? "" : "s"}${cov.some((c) => c.truncated) ? " (truncated)" : ""}`,
238
+ `${stamped} of ${report2.artifacts.length} artifacts stamped`
239
+ ].join(" \xB7 ");
240
+ write(pc3.dim(` ${line}`));
241
+ write("");
242
+ }
243
+ if (ctx.extra.length) {
244
+ printGroups(ctx.extra, write);
245
+ write("");
246
+ }
247
+ }
248
+ function printGroups(findings, write) {
249
+ for (const group of GROUPS) {
250
+ const rows = findings.filter((f) => group.kinds.includes(f.kind));
251
+ if (rows.length === 0) continue;
252
+ write("");
253
+ write(
254
+ ` ${group.tone(pc3.bold(group.title))}${" ".repeat(Math.max(1, 48 - group.title.length))}${pc3.dim(group.hint)}`
255
+ );
256
+ for (const f of rows) {
257
+ write(` ${f.id}${f.at ? pc3.dim(` (${f.at})`) : ""}`);
258
+ write(pc3.dim(` ${f.kind}: ${f.detail}`));
259
+ }
260
+ }
261
+ }
262
+ function printCheck(findings, write = console.log) {
263
+ for (const f of findings) {
264
+ write(`${pc3.red(f.kind.padEnd(24))} ${f.id}${f.at ? pc3.dim(` ${f.at}`) : ""}`);
265
+ write(pc3.dim(`${" ".repeat(25)}${f.detail}`));
266
+ }
267
+ }
268
+
269
+ // src/commands/check.ts
270
+ async function check(root, opts = {}) {
271
+ const ctx = await loadContext(root);
272
+ const findings = allFindings(ctx);
273
+ if (opts.json) {
274
+ console.log(
275
+ JSON.stringify({ findings, specs: ctx.specs.map((s) => s.spec.slug) }, null, 2)
276
+ );
277
+ return findings.length ? 1 : 0;
278
+ }
279
+ if (findings.length === 0) {
280
+ if (!opts.quiet)
281
+ console.log(
282
+ pc4.green(
283
+ `redspec: clean. ${ctx.specs.length} spec${ctx.specs.length === 1 ? "" : "s"}, ${ctx.reports.reduce((n, r) => n + r.artifacts.length, 0)} artifacts.`
284
+ )
285
+ );
286
+ return 0;
287
+ }
288
+ if (!opts.quiet) printCheck(findings);
289
+ console.log("");
290
+ console.log(
291
+ pc4.red(`${findings.length} finding${findings.length === 1 ? "" : "s"}. Exit 1.`) + pc4.dim(" redspec status for the grouped view.")
292
+ );
293
+ return 1;
294
+ }
295
+
296
+ // src/commands/doctor.ts
297
+ import { existsSync as existsSync6 } from "fs";
298
+ import { join as join6 } from "path";
299
+ import pc6 from "picocolors";
300
+ import { DIGEST_ALGO, HARNESSES } from "@redspec/core";
301
+ import { CAPABILITIES } from "@redspec/method";
302
+
303
+ // src/harness.ts
304
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
305
+ import { join as join2 } from "path";
306
+ function detectHarnesses(root, env = process.env) {
307
+ const found = [];
308
+ const has = (p2) => existsSync2(join2(root, p2));
309
+ if (has(".claude") || has("CLAUDE.md") || env.CLAUDECODE)
310
+ found.push({
311
+ harness: "claude",
312
+ evidence: has(".claude") ? ".claude/" : has("CLAUDE.md") ? "CLAUDE.md" : "$CLAUDECODE"
313
+ });
314
+ if (has(".cursor") || has(".cursorrules") || env.CURSOR_TRACE_ID)
315
+ found.push({
316
+ harness: "cursor",
317
+ evidence: has(".cursor") ? ".cursor/" : ".cursorrules"
318
+ });
319
+ const agentsMd = has("AGENTS.md") ? readFileSync2(join2(root, "AGENTS.md"), "utf8") : null;
320
+ const foreignAgentsMd = agentsMd !== null && agentsMd.replace(/<!-- redspec:start -->[\s\S]*?<!-- redspec:end -->/g, "").trim().length > 0;
321
+ if (has(".codex") || foreignAgentsMd)
322
+ found.push({ harness: "codex", evidence: has(".codex") ? ".codex/" : "AGENTS.md" });
323
+ if (has(".github/copilot-instructions.md") || has(".github/prompts"))
324
+ found.push({ harness: "copilot", evidence: ".github/copilot-instructions.md" });
325
+ if (has(".gemini") || has("GEMINI.md"))
326
+ found.push({ harness: "gemini", evidence: has(".gemini") ? ".gemini/" : "GEMINI.md" });
327
+ return found;
328
+ }
329
+ function detectFramework(pkg) {
330
+ const deps = {
331
+ ...pkg?.dependencies ?? {},
332
+ ...pkg?.devDependencies ?? {}
333
+ };
334
+ if (deps.next) return "next";
335
+ return "none";
336
+ }
337
+ function hasTestRunner(pkg) {
338
+ const deps = {
339
+ ...pkg?.dependencies ?? {},
340
+ ...pkg?.devDependencies ?? {}
341
+ };
342
+ return {
343
+ unit: deps.vitest ? "vitest" : deps.jest ? "jest" : null,
344
+ browser: deps["@playwright/test"] ? "playwright" : null
345
+ };
346
+ }
347
+
348
+ // src/install.ts
349
+ import { spawnSync as spawnSync2 } from "child_process";
350
+ import { existsSync as existsSync3 } from "fs";
351
+ import { join as join3 } from "path";
352
+ import pc5 from "picocolors";
353
+ function missingDeps(root, deps) {
354
+ return deps.filter((d) => !existsSync3(join3(root, "node_modules", d)));
355
+ }
356
+ function installArgs(pm, deps, dev) {
357
+ if (pm === "npm")
358
+ return ["npm", "install", "--save-exact", dev ? "--save-dev" : "--save", ...deps];
359
+ if (pm === "yarn") return ["yarn", "add", "--exact", ...dev ? ["--dev"] : [], ...deps];
360
+ if (pm === "bun") return ["bun", "add", "--exact", ...dev ? ["--dev"] : [], ...deps];
361
+ return ["pnpm", "add", "--save-exact", dev ? "--save-dev" : "--save-prod", ...deps];
362
+ }
363
+ var installCommand = (pm, deps, dev) => installArgs(pm, deps, dev).join(" ");
364
+ function installDeps(root, pm, deps, dev, log) {
365
+ if (!deps.length) return true;
366
+ log("");
367
+ log(`Installing ${dev ? "devDependencies" : "dependencies"} (${pm}):`);
368
+ for (const d of deps) log(` ${pc5.cyan(d)}`);
369
+ const [cmd, ...args] = installArgs(pm, deps, dev);
370
+ const run2 = spawnSync2(cmd, args, { cwd: root, stdio: "inherit" });
371
+ return run2.status === 0;
372
+ }
373
+
374
+ // src/commands/sync.ts
375
+ import { createHash } from "crypto";
376
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
377
+ import { join as join5 } from "path";
378
+ import {
379
+ readMethod,
380
+ renderHarness
381
+ } from "@redspec/method";
382
+
383
+ // src/fs.ts
384
+ import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
385
+ import { dirname, join as join4 } from "path";
386
+ import { mergeSection } from "@redspec/method";
387
+ var Writer = class {
388
+ constructor(root) {
389
+ this.root = root;
390
+ }
391
+ root;
392
+ written = [];
393
+ /** Write only if the file does not exist. */
394
+ create(rel, content) {
395
+ const abs = join4(this.root, rel);
396
+ if (existsSync4(abs)) {
397
+ this.written.push({ path: rel, action: "kept" });
398
+ return;
399
+ }
400
+ mkdirSync(dirname(abs), { recursive: true });
401
+ writeFileSync(abs, content);
402
+ this.written.push({ path: rel, action: "created" });
403
+ }
404
+ /** Overwrite; these are generated files that carry a "generated" header. */
405
+ write(rel, content) {
406
+ const abs = join4(this.root, rel);
407
+ const existed = existsSync4(abs);
408
+ if (existed && readFileSync3(abs, "utf8") === content) {
409
+ this.written.push({ path: rel, action: "kept" });
410
+ return;
411
+ }
412
+ mkdirSync(dirname(abs), { recursive: true });
413
+ writeFileSync(abs, content);
414
+ this.written.push({ path: rel, action: existed ? "updated" : "created" });
415
+ }
416
+ /** Replace or append a marked section, leaving the rest of the file alone. */
417
+ section(rel, content) {
418
+ const abs = join4(this.root, rel);
419
+ const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : null;
420
+ const next = mergeSection(existing, content);
421
+ if (existing === next) {
422
+ this.written.push({ path: rel, action: "kept" });
423
+ return;
424
+ }
425
+ mkdirSync(dirname(abs), { recursive: true });
426
+ writeFileSync(abs, next);
427
+ this.written.push({ path: rel, action: existing ? "updated" : "created" });
428
+ }
429
+ append(rel, content) {
430
+ const abs = join4(this.root, rel);
431
+ mkdirSync(dirname(abs), { recursive: true });
432
+ writeFileSync(abs, (existsSync4(abs) ? readFileSync3(abs, "utf8") : "") + content);
433
+ this.written.push({ path: rel, action: "updated" });
434
+ }
435
+ read(rel) {
436
+ const abs = join4(this.root, rel);
437
+ return existsSync4(abs) ? readFileSync3(abs, "utf8") : null;
438
+ }
439
+ };
440
+
441
+ // src/commands/sync.ts
442
+ var CONTEXT_LOCK = ".redspec/contexts.json";
443
+ var digest = (s) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16);
444
+ function renderContext(root, config) {
445
+ const pm = packageManager(root);
446
+ return {
447
+ specsDir: config.specsDir,
448
+ route: config.route,
449
+ framework: config.framework,
450
+ unitCommand: runScript(pm, "test"),
451
+ stateCommand: runScript(pm, "test:state"),
452
+ journeyCommand: runScript(pm, "test:journey"),
453
+ conventionsPath: "docs/agents/redspec.md",
454
+ publicBoard: config.publicBoard
455
+ };
456
+ }
457
+ function sync(root, config, harnesses = config.harnesses) {
458
+ const method = readMethod();
459
+ const ctx = renderContext(root, config);
460
+ const w = new Writer(root);
461
+ const lock = { version: 1, files: {} };
462
+ const files = /* @__PURE__ */ new Map();
463
+ for (const h of harnesses) {
464
+ for (const f of renderHarness(h, method, ctx)) {
465
+ const prev = files.get(f.path);
466
+ if (!prev || f.content.length > prev.content.length) files.set(f.path, f);
467
+ }
468
+ }
469
+ for (const f of files.values()) {
470
+ if (f.mode === "section") w.section(f.path, f.content);
471
+ else w.write(f.path, f.content);
472
+ lock.files[f.path] = digest(f.content);
473
+ }
474
+ w.write(CONTEXT_LOCK, JSON.stringify(lock, null, 2) + "\n");
475
+ return w;
476
+ }
477
+ function staleContexts(root, config) {
478
+ const lockPath = join5(root, CONTEXT_LOCK);
479
+ if (!existsSync5(lockPath)) return [];
480
+ const lock = JSON.parse(readFileSync4(lockPath, "utf8"));
481
+ const method = readMethod();
482
+ const ctx = renderContext(root, config);
483
+ const files = /* @__PURE__ */ new Map();
484
+ for (const h of config.harnesses) {
485
+ for (const f of renderHarness(h, method, ctx)) {
486
+ const prev = files.get(f.path);
487
+ if (!prev || f.content.length > prev.content.length) files.set(f.path, f);
488
+ }
489
+ }
490
+ return [...files.values()].filter((f) => lock.files[f.path] !== digest(f.content)).map((f) => f.path);
491
+ }
492
+
493
+ // src/commands/doctor.ts
494
+ async function doctor(root) {
495
+ let problems = 0;
496
+ const ok = (s) => console.log(` ${pc6.green("\u2713")} ${s}`);
497
+ const warn = (s) => console.log(` ${pc6.yellow("!")} ${s}`);
498
+ const bad = (s) => {
499
+ problems++;
500
+ console.log(` ${pc6.red("\u2717")} ${s}`);
501
+ };
502
+ let ctx;
503
+ try {
504
+ ctx = await loadContext(root);
505
+ } catch (e) {
506
+ bad(`Specs failed to load: ${e.message}`);
507
+ return 1;
508
+ }
509
+ console.log(pc6.bold("Config"));
510
+ ctx.configPath ? ok(
511
+ `spec.config.ts (framework: ${ctx.config.framework}, route: ${ctx.config.route})`
512
+ ) : bad("No spec.config.ts. Run `redspec init`.");
513
+ ok(
514
+ `${ctx.specs.length} feature${ctx.specs.length === 1 ? "" : "s"} load${ctx.specs.length === 1 ? "s" : ""}`
515
+ );
516
+ for (const r of ctx.reports) {
517
+ if (r.lock.algo !== 0 && r.lock.algo !== DIGEST_ALGO)
518
+ bad(
519
+ `${r.slug}: lock algo ${r.lock.algo}, this redspec writes ${DIGEST_ALGO}. Run \`redspec accept\` over its claims after upgrading.`
520
+ );
521
+ }
522
+ for (const f of ctx.extra) warn(`${f.id}: ${f.detail}`);
523
+ if (ctx.config.framework === "next") {
524
+ console.log(pc6.bold("\nNext.js"));
525
+ const src = existsSync6(join6(root, "src/app")) ? "src/" : "";
526
+ const has = (p2) => existsSync6(join6(root, src + p2));
527
+ has("proxy.ts") || has("proxy.redspec.ts") ? ok("proxy.ts gates the spec route in production") : bad("No proxy.ts. The spec route will serve in production.");
528
+ has("app/spec/_routes.ts") ? ok("app/spec/ routes present") : bad("app/spec/_routes.ts missing.");
529
+ existsSync6(join6(root, ctx.config.specsDir, "index.ts")) ? ok(`${ctx.config.specsDir}/index.ts registers features for the app`) : bad(`${ctx.config.specsDir}/index.ts missing.`);
530
+ }
531
+ console.log(pc6.bold("\nDependencies"));
532
+ const pm = packageManager(root);
533
+ const runners = hasTestRunner(ctx.pkg);
534
+ const runtime = ["@redspec/core", "@redspec/cli"];
535
+ if (ctx.config.framework === "next") runtime.push("@redspec/next");
536
+ const dev = [
537
+ "fast-check",
538
+ runners.unit === "jest" ? "jest" : "vitest",
539
+ "@playwright/test"
540
+ ];
541
+ const needRuntime = missingDeps(root, runtime);
542
+ const needDev = missingDeps(root, dev);
543
+ const missing = /* @__PURE__ */ new Set([...needRuntime, ...needDev]);
544
+ for (const d of [...runtime, ...dev]) {
545
+ missing.has(d) ? bad(`${d} is not installed.`) : ok(d);
546
+ }
547
+ if (needRuntime.length)
548
+ console.log(pc6.dim(` ${installCommand(pm, needRuntime, false)}`));
549
+ if (needDev.length) console.log(pc6.dim(` ${installCommand(pm, needDev, true)}`));
550
+ console.log(pc6.bold("\nHarnesses"));
551
+ const detected = new Set(detectHarnesses(root).map((d) => d.harness));
552
+ for (const h of HARNESSES) {
553
+ const configured = ctx.config.harnesses.includes(h);
554
+ const cap = CAPABILITIES[h];
555
+ const mark = configured ? pc6.green("\u2713") : detected.has(h) ? pc6.yellow("!") : pc6.dim("\u25CB");
556
+ const label = configured ? "configured" : detected.has(h) ? "detected, not configured \u2014 add to spec.config.ts harnesses and `redspec sync`" : "";
557
+ console.log(` ${mark} ${h.padEnd(9)} ${pc6.dim(label)}`);
558
+ if (configured) {
559
+ console.log(
560
+ ` ${cap.hitlOnly ? pc6.green("steps are HITL-only") : pc6.yellow("steps are conventions, not gates")} \xB7 ${cap.subagents ? "adversary/verifier run as subagents" : "adversary/verifier run as fresh tasks"}`
561
+ );
562
+ console.log(pc6.dim(` ${cap.note}`));
563
+ }
564
+ }
565
+ const stale = staleContexts(root, ctx.config);
566
+ if (stale.length) {
567
+ warn(
568
+ `${stale.length} rendered context file${stale.length === 1 ? " is" : "s are"} stale (redspec upgraded, or config changed). Run \`redspec sync\`.`
569
+ );
570
+ for (const s of stale) console.log(pc6.dim(` ${s}`));
571
+ } else if (ctx.config.harnesses.length)
572
+ ok("rendered contexts match this redspec version");
573
+ console.log("");
574
+ console.log(
575
+ problems ? pc6.red(`${problems} problem${problems === 1 ? "" : "s"}.`) : pc6.green("Healthy.")
576
+ );
577
+ return problems ? 1 : 0;
578
+ }
579
+
580
+ // src/commands/init.ts
581
+ import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
582
+ import { join as join7 } from "path";
583
+ import * as p from "@clack/prompts";
584
+ import pc7 from "picocolors";
585
+ import {
586
+ defineSpecConfig,
587
+ HARNESSES as HARNESSES2,
588
+ loadConfig as loadConfig2
589
+ } from "@redspec/core";
590
+ import { CAPABILITIES as CAPABILITIES2 } from "@redspec/method";
591
+
592
+ // src/templates.ts
593
+ var specConfig = (framework, harnesses) => `import { defineSpecConfig } from "@redspec/core"
594
+
595
+ export default defineSpecConfig({
596
+ framework: "${framework}",
597
+ route: "/spec",
598
+ caseViewport: { width: 1280, height: 720 },
599
+ // "witnessed" makes every waiver name the INV- that would go red if it stopped holding.
600
+ waivers: "free",
601
+ // Must exit 0 in the same invocation for \`redspec accept\` to stamp anything.
602
+ accept: { command: "pnpm test && pnpm test:state" },
603
+ // Which agent harnesses \`redspec sync\` writes context for.
604
+ harnesses: ${JSON.stringify(harnesses)},
605
+ })
606
+ `;
607
+ var proxy = `import { createSpecProxy } from "@redspec/next/gate"
608
+
609
+ // The production gate. It answers 404 before anything renders: a layout-level
610
+ // notFound() still serializes the page into the response body.
611
+ export const proxy = createSpecProxy({ route: "/spec" })
612
+
613
+ // Next statically parses this at build time, so every value has to stay a
614
+ // literal -- a helper call here fails the build.
615
+ export const config = { matcher: ["/spec", "/spec/:path*"] }
616
+ `;
617
+ var specsIndex = `// Every feature this repo declares. \`redspec new feature\` appends here.
618
+ import type { Spec } from "@redspec/core"
619
+
620
+ // redspec:imports
621
+
622
+ export const specs: Spec[] = [
623
+ // redspec:specs
624
+ ]
625
+ `;
626
+ var nextRoutes = (config) => `import { createSpecRoutes } from "@redspec/next"
627
+ import { specs } from "../../specs"
628
+
629
+ export const { SpecLayout, SpecIndexPage, SpecBoardPage, SpecCasePage, generateStaticParams } =
630
+ createSpecRoutes(specs, {
631
+ route: ${JSON.stringify(config.route)},
632
+ specsDir: ${JSON.stringify(config.specsDir)},
633
+ stateTestsDir: ${JSON.stringify(config.stateTestsDir)},
634
+ })
635
+ `;
636
+ var nextLayout = `export { SpecLayout as default } from "./_routes"
637
+ `;
638
+ var nextIndex = `export { SpecIndexPage as default } from "./_routes"
639
+ `;
640
+ var nextBoard = `export { SpecBoardPage as default, generateStaticParams } from "../_routes"
641
+ `;
642
+ var nextCase = `export { SpecCasePage as default } from "../../_routes"
643
+ `;
644
+ var playwrightConfig = `import { defineConfig, devices } from "@playwright/test"
645
+
646
+ // Two tiers, differing by target rather than by tool. \`state\` points at the
647
+ // spec route -- fixtures only, no auth, no backend -- and asserts exhaustively.
648
+ // \`journey\` points at the real app and stays a handful of paths. Both run
649
+ // against the dev server: the spec route 404s in production by design.
650
+ export default defineConfig({
651
+ testDir: "./e2e",
652
+ fullyParallel: true,
653
+ forbidOnly: !!process.env.CI,
654
+ retries: process.env.CI ? 2 : 0,
655
+ reporter: process.env.CI ? "github" : "list",
656
+ expect: { toHaveScreenshot: { stylePath: "./e2e/screenshot.css" } },
657
+ use: { baseURL: "http://localhost:3000", trace: "on-first-retry" },
658
+ projects: [
659
+ { name: "state", testDir: "./e2e/state", use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 } } },
660
+ { name: "journey", testDir: "./e2e/journey", use: { ...devices["Desktop Chrome"] } },
661
+ ],
662
+ webServer: { command: "pnpm dev", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, timeout: 120_000 },
663
+ })
664
+ `;
665
+ var screenshotCss = `/* Injected into every screenshot. The Next dev-tools indicator changes with the
666
+ * dev server's state, so it stays out of frame. */
667
+ nextjs-portal { display: none !important; }
668
+ `;
669
+ var ciWorkflow = `name: redspec
670
+ on:
671
+ pull_request:
672
+ push:
673
+ branches: [main]
674
+ jobs:
675
+ spec:
676
+ runs-on: ubuntu-latest
677
+ steps:
678
+ - uses: actions/checkout@v4
679
+ - uses: pnpm/action-setup@v4
680
+ - uses: actions/setup-node@v4
681
+ with: { node-version: 22, cache: pnpm }
682
+ - run: pnpm install --frozen-lockfile
683
+ # The gate. Red on a skeleton is correct; red on main is a stop.
684
+ - run: pnpm exec redspec check
685
+ - run: pnpm test
686
+ `;
687
+ var brief = (slug, title2) => `# ${title2}
688
+
689
+ ## Problem
690
+
691
+ What is wrong today, from the perspective of the person it is wrong for. Two or three sentences.
692
+
693
+ ## Actors
694
+
695
+ - **Someone**: what they want from this. One bolded bullet per actor; the audit reads this list and fails on an actor with no flow.
696
+
697
+ ## What changes
698
+
699
+ The shortest honest statement of the new capability.
700
+
701
+ ## Non-goals
702
+
703
+ - **Something a reader would assume is included.** Why it is not.
704
+
705
+ ## Deliberate unknowns
706
+
707
+ - **A question knowingly left open.** What happens if the guess is wrong, and how expensive that is.
708
+ `;
709
+ var specTs = (slug, title2) => `import { defineSpec } from "@redspec/core"
710
+ import { copy } from "./copy"
711
+ import * as fixtures from "./fixtures"
712
+ import * as sketches from "./sketches"
713
+
714
+ // \`redspec status\` is the work list. After /draft-skeleton, \`surfaces\` and
715
+ // \`flows\` are filled and \`cases\` is empty: every declared state is a stub on
716
+ // the board and a red line in status. /render-states fills \`cases\`.
717
+ export default defineSpec({
718
+ slug: "${slug}",
719
+ title: "${title2}",
720
+
721
+ surfaces: {
722
+ // <key>: {
723
+ // title: "The screen",
724
+ // checklist: {
725
+ // empty: { state: "STATE-${slug}-<key>-empty" },
726
+ // loading: { state: "STATE-${slug}-<key>-loading" },
727
+ // partial: { waived: "Why this screen cannot be half-loaded.", witness: "INV-\u2026" },
728
+ // populated: { state: "STATE-${slug}-<key>-populated" },
729
+ // overflowing: { state: "STATE-${slug}-<key>-overflowing" },
730
+ // recoverableError: { state: "STATE-${slug}-<key>-retry" },
731
+ // terminalError: { state: "STATE-${slug}-<key>-failed" },
732
+ // permissionDenied: { state: "STATE-${slug}-<key>-read-only" },
733
+ // stale: { waived: "Reads are live.", review: "2027-01-01" },
734
+ // inFlight: { state: "STATE-${slug}-<key>-saving" },
735
+ // terminalSuccess: { waived: "A place, not a flow that finishes." },
736
+ // conflict: { state: "STATE-${slug}-<key>-conflict" },
737
+ // },
738
+ // },
739
+ },
740
+
741
+ // What each declared state *is*, in one line -- said where it is declared,
742
+ // because the board is read from here until /render-states, and a state
743
+ // whose only name is its ID is a state nobody can review. Say what the
744
+ // person is looking at, not which row it answers.
745
+ states: {
746
+ // "STATE-${slug}-<key>-empty": "Nothing here yet, and one button to start",
747
+ },
748
+
749
+ cases: {},
750
+
751
+ flows: [
752
+ // {
753
+ // id: "JOURNEY-${slug}-<intent>",
754
+ // title: "What the actor gets",
755
+ // actor: "Someone", // must match a bolded actor in BRIEF.md
756
+ // spine: [
757
+ // { case: "STATE-${slug}-<key>-empty", on: "Does the first thing" },
758
+ // { case: "STATE-${slug}-<key>-populated", end: "What they are left with." },
759
+ // ],
760
+ // deviations: [
761
+ // { from: "STATE-${slug}-<key>-populated", when: "Cold cache", case: "STATE-${slug}-<key>-loading", rejoins: "STATE-${slug}-<key>-populated" },
762
+ // ],
763
+ // },
764
+ ],
765
+ })
766
+
767
+ // Keep the imports live so the skeleton typechecks before any case uses them.
768
+ void copy
769
+ void fixtures
770
+ void sketches
771
+ `;
772
+ var copyTs = (slug) => `import { defineCopy } from "@redspec/core"
773
+
774
+ // Every user-facing string this feature ships, once. Sketches render from it;
775
+ // assertions assert against it. A word changes here and both readers see it.
776
+ export const copy = defineCopy({
777
+ // "COPY-${slug}-<key>-empty-title": "Nothing here yet",
778
+ })
779
+ `;
780
+ var fixturesTs = () => `// Fixtures for the cases. Plain data, no network, no database: a case that
781
+ // reaches for either is a Journey wearing a State's clothes.
782
+ export {}
783
+ `;
784
+ var sketchesTsx = () => `// Sketch markup for the cases. Drafts: a slice promotes them into components/
785
+ // and the assertions survive unchanged, which is why those are written in
786
+ // user intent rather than against a selector.
787
+ export {}
788
+ `;
789
+ var stateSpec = (slug) => `import { expect, test } from "@playwright/test"
790
+ import { copy } from "../../specs/${slug}/copy"
791
+
792
+ // One behavioural assertion and one screenshot per state, named for its ID,
793
+ // written in user intent. \`redspec new state <ID>\` appends here.
794
+ void copy
795
+ void expect
796
+ void test
797
+ `;
798
+ var journeySpecHeader = (slug) => `import { test } from "@playwright/test"
799
+
800
+ // Generated by \`redspec new journeys ${slug}\` from the flows in spec.ts \u2014 one
801
+ // per reachable path. Regenerate rather than edit. Each stays fixme until the
802
+ // slice that claims its JOURNEY- lands and un-fixmes it.
803
+ `;
804
+ var journeyTest = (id, index, states, labels, end) => {
805
+ const steps = states.map((s, i) => labels[i] ? ` // ${s}
806
+ // \u2192 ${labels[i]}` : ` // ${s}`).join("\n");
807
+ return `
808
+ test.fixme("${id} [path ${index + 1}]: ${end.replace(/"/g, '\\"')}", async ({ page }) => {
809
+ ${steps}
810
+ // Ends: ${end}
811
+ await page.goto("/")
812
+ })
813
+ `;
814
+ };
815
+ var stateFixture = (id) => `
816
+ // ${id}
817
+ export const ${camel(id)} = {}
818
+ `;
819
+ var stateSketch = (id, component) => `
820
+ // ${id}
821
+ export function ${component}() {
822
+ return <div>{/* ${id} */}</div>
823
+ }
824
+ `;
825
+ var stateAssertion = (id) => `
826
+ test("${id} <what a reviewer would say out loud about it>", async ({ page }) => {
827
+ await page.goto("/spec/<slug>/${id}")
828
+ // await expect(page.getByText(copy["COPY-\u2026"])).toBeVisible()
829
+ await expect(page).toHaveScreenshot("${id}.png")
830
+ })
831
+ `;
832
+ var caseSnippet = (id, surface, component, fixture) => ` "${id}": {
833
+ surface: "${surface}",
834
+ render: () => <sketches.${component} {...fixtures.${fixture}} />,
835
+ },`;
836
+ var stateNameSnippet = (id) => ` "${id}": "<what the person is looking at>",`;
837
+ function camel(id) {
838
+ return id.replace(/^(STATE|RULE|INV|JOURNEY|COPY)-/, "").split("-").map((p2, i) => i === 0 ? p2 : p2[0].toUpperCase() + p2.slice(1)).join("");
839
+ }
840
+ function pascal(id) {
841
+ const c = camel(id);
842
+ return c[0].toUpperCase() + c.slice(1);
843
+ }
844
+ var ruleStub = (id) => `# ${id}
845
+
846
+ What this rule decides, in one sentence.
847
+
848
+ | Input | Output | Why |
849
+ | --- | --- | --- |
850
+ | the figure the person gave | in their words | their reason |
851
+
852
+ **Status:** stub. /implement-rules picks the rung.
853
+ `;
854
+ var ruleTable = (id) => `## ${id}
855
+
856
+ What this rule decides, in one sentence.
857
+
858
+ **Inputs:** amount: number(0..), plan: {free, pro}
859
+ **Hit policy:** UNIQUE
860
+
861
+ | amount | plan | outcome | note |
862
+ | -------- | ---- | ------- | ---- |
863
+ | [0..100] | - | allowed | under the limit |
864
+ | (100..] | free | blocked | free plans stop at 100 |
865
+ | (100..] | pro | allowed | pro plans have no limit |
866
+
867
+ <!-- \`redspec check\` proves this total and non-overlapping. Drive it from a test with
868
+ parseDecisionTable + decide + representativeInputs from @redspec/core. -->
869
+ `;
870
+ var ruleTableTest = (id) => `import { readFileSync } from "node:fs"
871
+ import { join } from "node:path"
872
+ import { describe, expect, it } from "vitest"
873
+ import { decide, parseDecisionTable, representativeInputs } from "@redspec/core"
874
+
875
+ // ${id}: the markdown table is the artifact a reviewer signs; this is plumbing.
876
+ const table = parseDecisionTable(readFileSync(join(import.meta.dirname, "${id}.md"), "utf8"))
877
+
878
+ // Replace with the real implementation under test.
879
+ const implementation = (input: Record<string, number | string | boolean>) => decide(table, input)
880
+
881
+ describe("${id}", () => {
882
+ it("agrees with the table in every region it distinguishes", () => {
883
+ for (const input of representativeInputs(table)) {
884
+ expect(implementation(input)).toEqual(decide(table, input))
885
+ }
886
+ })
887
+ })
888
+ `;
889
+ var ruleMachine = (id) => `// ${id}
890
+ //
891
+ // A lifecycle as an explicit states \xD7 events table. The empty cells are the
892
+ // point: an undefined transition is visible by inspection.
893
+
894
+ export type State = "draft" | "submitted" | "approved"
895
+ export type Event = "submit" | "approve" | "reject"
896
+
897
+ export const machine = {
898
+ draft: { submit: "submitted" },
899
+ submitted: { approve: "approved", reject: "draft" },
900
+ approved: {},
901
+ } as const satisfies Record<State, Partial<Record<Event, State>>>
902
+
903
+ export function next(state: State, event: Event): State | null {
904
+ return (machine[state] as Partial<Record<Event, State>>)[event] ?? null
905
+ }
906
+ `;
907
+ var ruleMachineTest = (id) => `import fc from "fast-check"
908
+ import { describe, expect, it } from "vitest"
909
+ import { machine, next, type Event, type State } from "./${id}"
910
+
911
+ // ${id}: two tests. The shape test proves the table is well-formed; the
912
+ // model-based run proves the implementation *is* the table.
913
+ describe("${id}", () => {
914
+ it("names every state", () => {
915
+ const states: State[] = ["draft", "submitted", "approved"]
916
+ for (const s of states) expect(machine).toHaveProperty(s)
917
+ })
918
+
919
+ it("the implementation tracks the table across random legal event sequences", () => {
920
+ const events: Event[] = ["submit", "approve", "reject"]
921
+ fc.assert(
922
+ fc.property(fc.array(fc.constantFrom(...events), { maxLength: 20 }), (seq) => {
923
+ let model: State = "draft"
924
+ // Replace \`sut\` with the real system under test and step it alongside the model.
925
+ let sut: State = "draft"
926
+ for (const e of seq) {
927
+ const to = next(model, e)
928
+ if (to === null) continue // illegal in the model: the SUT must refuse it too
929
+ model = to
930
+ sut = to
931
+ expect(sut).toBe(model)
932
+ }
933
+ })
934
+ )
935
+ })
936
+ })
937
+ `;
938
+ var ruleInvariant = (id) => `import fc from "fast-check"
939
+ import { describe, expect, it } from "vitest"
940
+
941
+ // ${id}: a sentence about the domain that admits no exception.
942
+ describe("${id}", () => {
943
+ it("holds for every input", () => {
944
+ fc.assert(
945
+ fc.property(fc.integer(), (n) => {
946
+ expect(n + 0).toBe(n) // replace with the property
947
+ })
948
+ )
949
+ })
950
+ })
951
+ `;
952
+ var ruleType = (id) => `// ${id}
953
+ //
954
+ // Make the illegal state unrepresentable and the rule needs no test.
955
+ export type Example =
956
+ | { status: "draft" }
957
+ | { status: "sent"; sentAt: Date }
958
+ `;
959
+ var slice = (title2, claims, amends) => `# ${title2}
960
+
961
+ **Delivers:** the end-to-end behaviour this makes work, from the user's perspective.
962
+
963
+ **Blocked by:** None.
964
+ ${amends.length ? `
965
+ **Amends:**
966
+
967
+ ${amends.map((a) => `- \`${a}\``).join("\n")}
968
+
969
+ **Because:** why the requirement moved.
970
+ ` : ""}
971
+ **Claims:**
972
+
973
+ ${claims.map((c) => `- \`${c}\``).join("\n") || "- `STATE-\u2026`"}
974
+
975
+ **Status:** ready
976
+ `;
977
+
978
+ // src/commands/init.ts
979
+ async function init(opts) {
980
+ const root = opts.root;
981
+ const log = opts.quiet ? () => {
982
+ } : console.log;
983
+ const pkgPath = join7(root, "package.json");
984
+ const pkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
985
+ if (!pkg)
986
+ throw new Error(
987
+ `No package.json in ${root}. Run redspec init at the root of a Node project.`
988
+ );
989
+ const detectedFramework = opts.framework ?? detectFramework(pkg);
990
+ const detectedHarnesses = detectHarnesses(root);
991
+ const runners = hasTestRunner(pkg);
992
+ const pm = packageManager(root);
993
+ let harnesses;
994
+ let framework = detectedFramework;
995
+ if (opts.harness !== void 0) {
996
+ harnesses = opts.harness.split(",").map((h) => h.trim()).filter((h) => HARNESSES2.includes(h));
997
+ } else if (opts.yes) {
998
+ const existing = existsSync7(join7(root, "spec.config.ts")) ? (await loadConfig2(root)).config.harnesses : null;
999
+ harnesses = existing ?? detectedHarnesses.map((d) => d.harness);
1000
+ } else {
1001
+ p.intro(pc7.bgCyan(pc7.black(" redspec ")));
1002
+ p.note(
1003
+ [
1004
+ `framework ${framework === "next" ? pc7.green("Next.js") : pc7.yellow("none detected \u2014 core only, no board")}`,
1005
+ `unit tests ${runners.unit ?? pc7.yellow("none \u2014 vitest will be added")}`,
1006
+ `browser ${runners.browser ?? pc7.yellow("none \u2014 @playwright/test will be added")}`,
1007
+ "",
1008
+ ...HARNESSES2.map((h) => {
1009
+ const d = detectedHarnesses.find((x) => x.harness === h);
1010
+ return `${d ? pc7.green("\u2713") : pc7.dim("\u25CB")} ${h.padEnd(9)} ${d ? pc7.dim(d.evidence) : ""}`;
1011
+ })
1012
+ ].join("\n"),
1013
+ "Detected"
1014
+ );
1015
+ const picked = await p.multiselect({
1016
+ message: "Write agent context for which harnesses?",
1017
+ options: HARNESSES2.map((h) => ({
1018
+ value: h,
1019
+ label: h,
1020
+ hint: CAPABILITIES2[h].hitlOnly ? "steps are HITL-only" : "steps are conventions; CI is the guardrail"
1021
+ })),
1022
+ initialValues: detectedHarnesses.map((d) => d.harness),
1023
+ required: false
1024
+ });
1025
+ if (p.isCancel(picked)) {
1026
+ p.cancel("Nothing written.");
1027
+ process.exit(1);
1028
+ }
1029
+ harnesses = picked;
1030
+ if (framework === "none") {
1031
+ const go = await p.confirm({
1032
+ message: "No supported framework found. Set up core only (specs, rules, lock, coverage \u2014 no spec route or board)?"
1033
+ });
1034
+ if (p.isCancel(go) || !go) {
1035
+ p.cancel("Nothing written.");
1036
+ process.exit(1);
1037
+ }
1038
+ }
1039
+ }
1040
+ const w = new Writer(root);
1041
+ const config = defineSpecConfig({ framework, harnesses });
1042
+ w.create("spec.config.ts", specConfig(framework, harnesses));
1043
+ w.create("specs/index.ts", specsIndex);
1044
+ w.create("specs/.gitkeep", "");
1045
+ w.create("e2e/state/.gitkeep", "");
1046
+ w.create("e2e/journey/.gitkeep", "");
1047
+ w.create("e2e/screenshot.css", screenshotCss);
1048
+ if (!runners.browser) w.create("playwright.config.ts", playwrightConfig);
1049
+ w.create(".github/workflows/redspec.yml", ciWorkflow);
1050
+ if (framework === "next") {
1051
+ const src = existsSync7(join7(root, "src/app")) ? "src/" : "";
1052
+ if (existsSync7(join7(root, `${src}proxy.ts`)) || existsSync7(join7(root, `${src}middleware.ts`))) {
1053
+ w.create(`${src}proxy.redspec.ts`, proxy);
1054
+ log(
1055
+ pc7.yellow(
1056
+ ` A proxy/middleware already exists. Wrote ${src}proxy.redspec.ts \u2014 merge its gate into yours.`
1057
+ )
1058
+ );
1059
+ } else {
1060
+ w.create(`${src}proxy.ts`, proxy);
1061
+ }
1062
+ w.create(`${src}app/spec/_routes.ts`, nextRoutes(config));
1063
+ w.create(`${src}app/spec/layout.tsx`, nextLayout);
1064
+ w.create(`${src}app/spec/page.tsx`, nextIndex);
1065
+ w.create(`${src}app/spec/[feature]/page.tsx`, nextBoard);
1066
+ w.create(`${src}app/spec/[feature]/[case]/page.tsx`, nextCase);
1067
+ }
1068
+ const scripts = pkg.scripts ?? {};
1069
+ const add = {
1070
+ spec: "redspec check",
1071
+ "spec:status": "redspec status",
1072
+ "test:state": "playwright test --project=state",
1073
+ "test:journey": "playwright test --project=journey"
1074
+ };
1075
+ if (!runners.unit) add.test = "vitest run";
1076
+ let changed = false;
1077
+ for (const [k, v] of Object.entries(add)) {
1078
+ if (!scripts[k]) {
1079
+ scripts[k] = v;
1080
+ changed = true;
1081
+ }
1082
+ }
1083
+ if (changed) {
1084
+ pkg.scripts = scripts;
1085
+ writeFileSync2(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
1086
+ w.written.push({ path: "package.json", action: "updated" });
1087
+ }
1088
+ const synced = sync(root, config, harnesses);
1089
+ w.written.push(...synced.written);
1090
+ const runtime = ["@redspec/core", "@redspec/cli"];
1091
+ if (framework === "next") runtime.push("@redspec/next");
1092
+ const dev = ["fast-check"];
1093
+ if (!runners.unit) dev.push("vitest");
1094
+ if (!runners.browser) dev.push("@playwright/test");
1095
+ const needRuntime = missingDeps(root, runtime);
1096
+ const needDev = missingDeps(root, dev);
1097
+ const installLines = [
1098
+ ...needRuntime.length ? [installCommand(pm, needRuntime, false)] : [],
1099
+ ...needDev.length ? [installCommand(pm, needDev, true)] : []
1100
+ ];
1101
+ if (!opts.quiet) {
1102
+ for (const f of w.written) {
1103
+ if (f.action === "kept") continue;
1104
+ log(
1105
+ ` ${f.action === "created" ? pc7.green("create") : pc7.cyan("update")} ${f.path}`
1106
+ );
1107
+ }
1108
+ }
1109
+ const installed = opts.skipInstall ? false : installDeps(root, pm, needRuntime, false, log) && installDeps(root, pm, needDev, true, log);
1110
+ if (!opts.quiet) {
1111
+ log("");
1112
+ log(pc7.bold("Next:"));
1113
+ if (installLines.length && !installed) for (const l of installLines) log(` ${l}`);
1114
+ log(` ${pm === "npm" ? "npx" : `${pm} exec`} redspec doctor`);
1115
+ if (harnesses.length) {
1116
+ log("");
1117
+ for (const h of harnesses) {
1118
+ log(` ${pc7.dim(h.padEnd(9))} ${CAPABILITIES2[h].note}`);
1119
+ }
1120
+ }
1121
+ log("");
1122
+ log(pc7.dim("Then: /draft-skeleton <an idea, at whatever resolution you have it>"));
1123
+ }
1124
+ return { writer: w, install: installLines, harnesses, framework };
1125
+ }
1126
+
1127
+ // src/commands/new.ts
1128
+ import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
1129
+ import { join as join8 } from "path";
1130
+ import pc8 from "picocolors";
1131
+ import { compileFlow, ID_PATTERN, simplePaths } from "@redspec/core";
1132
+ var title = (slug) => slug.split("-").map((p2) => p2[0].toUpperCase() + p2.slice(1)).join(" ");
1133
+ async function newFeature(root, slug, quiet = false) {
1134
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
1135
+ console.error(pc8.red(`"${slug}" is not a slug. Lowercase, hyphenated.`));
1136
+ return 1;
1137
+ }
1138
+ const ctx = await loadContext(root);
1139
+ const dir = join8(ctx.config.specsDir, slug);
1140
+ const w = new Writer(root);
1141
+ w.create(`${dir}/BRIEF.md`, brief(slug, title(slug)));
1142
+ w.create(`${dir}/${ctx.config.specFile}`, specTs(slug, title(slug)));
1143
+ w.create(`${dir}/copy.ts`, copyTs(slug));
1144
+ w.create(`${dir}/fixtures.ts`, fixturesTs());
1145
+ w.create(`${dir}/sketches.tsx`, sketchesTsx());
1146
+ w.create(`${dir}/rules/.gitkeep`, "");
1147
+ w.create(`${dir}/slices/.gitkeep`, "");
1148
+ w.create(`${ctx.config.stateTestsDir}/${slug}.spec.ts`, stateSpec(slug));
1149
+ w.create(`${ctx.config.journeyTestsDir}/${slug}.spec.ts`, journeySpecHeader(slug));
1150
+ const indexPath = join8(root, ctx.config.specsDir, "index.ts");
1151
+ if (existsSync8(indexPath)) {
1152
+ const ident = slug.replace(/-(\w)/g, (_, c) => c.toUpperCase()) + "Spec";
1153
+ let src = readFileSync6(indexPath, "utf8");
1154
+ if (!src.includes(`./${slug}/spec`)) {
1155
+ src = src.replace(
1156
+ "// redspec:imports",
1157
+ `import ${ident} from "./${slug}/spec"
1158
+ // redspec:imports`
1159
+ );
1160
+ src = src.replace(" // redspec:specs", ` ${ident},
1161
+ // redspec:specs`);
1162
+ writeFileSync3(indexPath, src);
1163
+ w.written.push({ path: `${ctx.config.specsDir}/index.ts`, action: "updated" });
1164
+ }
1165
+ }
1166
+ report(w, quiet);
1167
+ if (!quiet)
1168
+ console.log(
1169
+ pc8.dim(
1170
+ `
1171
+ Now fill ${dir}/BRIEF.md and declare surfaces and flows in ${dir}/${ctx.config.specFile}. \`redspec status\` is the work list.`
1172
+ )
1173
+ );
1174
+ return 0;
1175
+ }
1176
+ async function newState(root, id, opts) {
1177
+ if (!ID_PATTERN.test(id) || !id.startsWith("STATE-")) {
1178
+ console.error(pc8.red(`"${id}" is not a STATE- ID. Lowercase, hyphenated.`));
1179
+ return 1;
1180
+ }
1181
+ const ctx = await loadContext(root);
1182
+ const owner = ctx.specs.find((s) => id.startsWith(`STATE-${s.spec.slug}-`));
1183
+ if (!owner) {
1184
+ console.error(
1185
+ pc8.red(
1186
+ `No feature's slug prefixes "${id}". Features: ${ctx.specs.map((s) => s.spec.slug).join(", ") || "none"}.`
1187
+ )
1188
+ );
1189
+ return 1;
1190
+ }
1191
+ const slug = owner.spec.slug;
1192
+ const declared = ctx.reports.find((r) => r.slug === slug).artifacts.some((a) => a.id === id);
1193
+ if (!declared)
1194
+ console.log(
1195
+ pc8.yellow(
1196
+ ` "${id}" is not declared by any checklist row or flow yet. Declare it in ${ctx.config.specFile} too, or the audit will call it unclaimed.`
1197
+ )
1198
+ );
1199
+ const rel = (f) => join8(ctx.config.specsDir, slug, f);
1200
+ const local = id.slice(`STATE-${slug}-`.length);
1201
+ const component = pascal(local);
1202
+ const fixture = camel(local);
1203
+ const w = new Writer(root);
1204
+ if (!(w.read(rel("fixtures.ts")) ?? "").includes(`// ${id}`))
1205
+ w.append(rel("fixtures.ts"), stateFixture(id));
1206
+ if (!(w.read(rel("sketches.tsx")) ?? "").includes(`// ${id}`))
1207
+ w.append(rel("sketches.tsx"), stateSketch(id, component));
1208
+ const testPath = `${ctx.config.stateTestsDir}/${slug}.spec.ts`;
1209
+ if (!(w.read(testPath) ?? "").includes(`"${id} `))
1210
+ w.append(testPath, stateAssertion(id).replace("<slug>", slug));
1211
+ report(w, opts.quiet);
1212
+ const surface = opts.surface ?? Object.keys(owner.spec.surfaces)[0] ?? "<surface>";
1213
+ if (!opts.quiet) {
1214
+ console.log(`
1215
+ Add to \`states\` in ${rel(ctx.config.specFile)}:
1216
+ `);
1217
+ console.log(stateNameSnippet(id));
1218
+ console.log(`
1219
+ And to \`cases\`:
1220
+ `);
1221
+ console.log(caseSnippet(id, surface, component, fixture));
1222
+ console.log(
1223
+ pc8.dim(
1224
+ "\nThen fill the fixture, the sketch (strings via copy.ts), and the assertion (user intent, no selectors)."
1225
+ )
1226
+ );
1227
+ }
1228
+ return 0;
1229
+ }
1230
+ async function newRule(root, id, opts) {
1231
+ if (!ID_PATTERN.test(id) || !/^(RULE|INV)-/.test(id)) {
1232
+ console.error(pc8.red(`"${id}" is not a RULE- or INV- ID.`));
1233
+ return 1;
1234
+ }
1235
+ const ctx = await loadContext(root);
1236
+ const slug = opts.feature ?? (ctx.specs.length === 1 ? ctx.specs[0].spec.slug : void 0);
1237
+ if (!slug) {
1238
+ console.error(pc8.red("Pass --feature <slug>; more than one feature is declared."));
1239
+ return 1;
1240
+ }
1241
+ const dir = join8(ctx.config.specsDir, slug, "rules");
1242
+ const w = new Writer(root);
1243
+ switch (opts.form) {
1244
+ case "stub":
1245
+ w.create(`${dir}/${id}.md`, ruleStub(id));
1246
+ break;
1247
+ case "table":
1248
+ w.create(`${dir}/${id}.md`, ruleTable(id));
1249
+ w.create(`${dir}/${id}.test.ts`, ruleTableTest(id));
1250
+ break;
1251
+ case "machine":
1252
+ w.create(`${dir}/${id}.ts`, ruleMachine(id));
1253
+ w.create(`${dir}/${id}.test.ts`, ruleMachineTest(id));
1254
+ break;
1255
+ case "invariant":
1256
+ w.create(`${dir}/${id}.test.ts`, ruleInvariant(id));
1257
+ w.create(`${dir}/${id}.ts`, `// ${id}: see ${id}.test.ts
1258
+ export {}
1259
+ `);
1260
+ break;
1261
+ case "type":
1262
+ w.create(`${dir}/${id}.ts`, ruleType(id));
1263
+ break;
1264
+ default:
1265
+ console.error(pc8.red(`--form must be stub, table, machine, invariant, or type.`));
1266
+ return 1;
1267
+ }
1268
+ report(w, opts.quiet);
1269
+ return 0;
1270
+ }
1271
+ async function newSlice(root, slug, name, opts) {
1272
+ const ctx = await loadContext(root);
1273
+ if (!ctx.specs.some((s) => s.spec.slug === slug)) {
1274
+ console.error(pc8.red(`No feature "${slug}".`));
1275
+ return 1;
1276
+ }
1277
+ if (!/^A?\d{2}-[a-z0-9-]+$/.test(name)) {
1278
+ console.error(
1279
+ pc8.red(
1280
+ `Slice files are <NN>-<name> (or A<NN>-<name> for an amendment): "${name}" is not.`
1281
+ )
1282
+ );
1283
+ return 1;
1284
+ }
1285
+ const heading = `${name.split("-")[0]}: ${title(name.split("-").slice(1).join("-"))}`;
1286
+ const w = new Writer(root);
1287
+ w.create(
1288
+ join8(ctx.config.specsDir, slug, "slices", `${name}.md`),
1289
+ slice(heading, opts.claims, opts.amends)
1290
+ );
1291
+ report(w, opts.quiet);
1292
+ return 0;
1293
+ }
1294
+ async function newJourneys(root, slug, quiet = false) {
1295
+ const ctx = await loadContext(root);
1296
+ const owner = ctx.specs.find((s) => s.spec.slug === slug);
1297
+ if (!owner) {
1298
+ console.error(pc8.red(`No feature "${slug}".`));
1299
+ return 1;
1300
+ }
1301
+ let out = journeySpecHeader(slug);
1302
+ let n = 0;
1303
+ for (const flow of owner.spec.flows) {
1304
+ const { paths, truncated } = simplePaths(compileFlow(flow), ctx.config.journeyBudget);
1305
+ paths.forEach((path, i) => {
1306
+ out += journeyTest(flow.id, i, path.states, path.labels, path.end);
1307
+ n++;
1308
+ });
1309
+ if (truncated)
1310
+ out += `
1311
+ // ${flow.id}: path enumeration stopped at the budget (${ctx.config.journeyBudget}). Raise journeyBudget in spec.config.ts, or simplify the flow.
1312
+ `;
1313
+ }
1314
+ const w = new Writer(root);
1315
+ w.write(`${ctx.config.journeyTestsDir}/${slug}.spec.ts`, out);
1316
+ report(w, quiet);
1317
+ if (!quiet)
1318
+ console.log(
1319
+ pc8.dim(
1320
+ `${n} path${n === 1 ? "" : "s"} across ${owner.spec.flows.length} flow${owner.spec.flows.length === 1 ? "" : "s"}.`
1321
+ )
1322
+ );
1323
+ return 0;
1324
+ }
1325
+ function report(w, quiet) {
1326
+ if (quiet) return;
1327
+ for (const f of w.written) {
1328
+ if (f.action === "kept") continue;
1329
+ console.log(
1330
+ ` ${f.action === "created" ? pc8.green("create") : pc8.cyan("update")} ${f.path}`
1331
+ );
1332
+ }
1333
+ }
1334
+
1335
+ // src/commands/status.ts
1336
+ async function status(root, opts = {}) {
1337
+ const ctx = await loadContext(root);
1338
+ if (opts.ids !== void 0) {
1339
+ const reports = opts.ids ? ctx.reports.filter((r) => r.slug === opts.ids) : ctx.reports;
1340
+ for (const r of reports) for (const a of r.artifacts) console.log(a.id);
1341
+ return 0;
1342
+ }
1343
+ printStatus(ctx);
1344
+ return 0;
1345
+ }
1346
+
1347
+ // src/index.ts
1348
+ function program() {
1349
+ const cli = new Command("redspec").description("Specs made of artifacts that can go red.").option("-C, --cwd <dir>", "repo root", process.cwd()).exitOverride();
1350
+ const root = () => cli.opts().cwd;
1351
+ cli.command("init").description(
1352
+ "Set this repo up: config, spec route, test tiers, and agent context for the harnesses you use."
1353
+ ).option("-y, --yes", "accept detected defaults, no prompts").option("--harness <list>", "comma-separated: claude,cursor,codex,copilot,gemini").option("--framework <name>", "next | none").option("--skip-install", "write the files, install nothing").action(
1354
+ async (o) => {
1355
+ await init({ root: root(), ...o });
1356
+ }
1357
+ );
1358
+ cli.command("check").description(
1359
+ "The gate: audit, coverage, decision tables, lock. Exit 1 on any finding."
1360
+ ).option("--json", "machine-readable").option("-q, --quiet", "only the summary line").action(
1361
+ async (o) => process.exit(await check(root(), o))
1362
+ );
1363
+ cli.command("status").description("The work list, in English.").option("--ids [slug]", "list artifact IDs (optionally for one feature) and exit").action(
1364
+ async (o) => process.exit(
1365
+ await status(root(), {
1366
+ ids: o.ids === true ? "" : o.ids === false ? void 0 : o.ids
1367
+ })
1368
+ )
1369
+ );
1370
+ const n = cli.command("new").description("Scaffold an artifact. Never hand-write what this writes.");
1371
+ n.command("feature <slug>").description(
1372
+ "a bundle: BRIEF, spec.ts, copy, fixtures, sketches, rules/, slices/, both test files"
1373
+ ).action(async (slug) => process.exit(await newFeature(root(), slug)));
1374
+ n.command("state <id>").description("fixture, sketch, and assertion scaffolds for a declared STATE-").option("--surface <key>").action(
1375
+ async (id, o) => process.exit(await newState(root(), id, o))
1376
+ );
1377
+ n.command("rule <id>").description("a rule on a rung").requiredOption("--form <form>", "stub | table | machine | invariant | type").option("--feature <slug>").action(
1378
+ async (id, o) => process.exit(await newRule(root(), id, o))
1379
+ );
1380
+ n.command("slice <slug> <name>").description("a slice file: <NN>-<name>, or A<NN>-<name> for an amendment").option("--claims <ids...>", "artifact IDs", []).option("--amends <ids...>", "artifact IDs this amends", []).action(
1381
+ async (slug, name, o) => process.exit(await newSlice(root(), slug, name, o))
1382
+ );
1383
+ n.command("journeys <slug>").description(
1384
+ "regenerate the journey tier from the flows, one fixme per reachable path"
1385
+ ).action(async (slug) => process.exit(await newJourneys(root(), slug)));
1386
+ cli.command("accept [ids...]").description(
1387
+ "Re-stamp artifacts after the verification command passes in this same run."
1388
+ ).option("--slice <path>", "stamp every claim of one slice").option("--clarification <note>", "record that the change was wording, not behaviour").option("--command <cmd>", "override the configured verification command").action(
1389
+ async (ids, o) => process.exit(await accept(root(), { ids, ...o }))
1390
+ );
1391
+ cli.command("sync").description("Re-render agent context for the configured harnesses.").action(async () => {
1392
+ const ctx = await loadContext(root());
1393
+ const w = sync(root(), ctx.config);
1394
+ for (const f of w.written)
1395
+ if (f.action !== "kept") console.log(` ${f.action.padEnd(8)}${f.path}`);
1396
+ });
1397
+ cli.command("doctor").description("Verify the install and say what each harness can and cannot enforce.").action(async () => process.exit(await doctor(root())));
1398
+ cli.command("board [feature]").description("Start the dev server and print the board URL.").action(async (feature) => process.exit(await board(root(), feature)));
1399
+ return cli;
1400
+ }
1401
+ async function run(argv) {
1402
+ try {
1403
+ await program().parseAsync(argv);
1404
+ } catch (e) {
1405
+ const err = e;
1406
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version" || err.code === "commander.help")
1407
+ return;
1408
+ console.error(err.message ?? String(e));
1409
+ process.exit(1);
1410
+ }
1411
+ }
1412
+ export {
1413
+ accept,
1414
+ check,
1415
+ detectFramework,
1416
+ detectHarnesses,
1417
+ doctor,
1418
+ init,
1419
+ loadContext,
1420
+ newFeature,
1421
+ newJourneys,
1422
+ newRule,
1423
+ newSlice,
1424
+ newState,
1425
+ program,
1426
+ run,
1427
+ status,
1428
+ sync
1429
+ };