@rsaglobaltech/specgate 0.25.0 → 0.26.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +53 -42
  2. package/dist/packages/core/src/domain/AgentPrompt.js +12 -0
  3. package/dist/packages/core/src/domain/AgentPrompt.js.map +1 -1
  4. package/dist/packages/core/src/domain/Clarification.js +5 -0
  5. package/dist/packages/core/src/domain/Clarification.js.map +1 -1
  6. package/dist/packages/core/src/domain/HarnessConfig.js.map +1 -1
  7. package/dist/packages/core/src/domain/RequirementPlan.js +11 -0
  8. package/dist/packages/core/src/domain/RequirementPlan.js.map +1 -1
  9. package/dist/packages/core/src/infrastructure/ChangeWorkspace.js +15 -3
  10. package/dist/packages/core/src/infrastructure/ChangeWorkspace.js.map +1 -1
  11. package/dist/packages/core/src/infrastructure/HarnessConfigFile.js +5 -0
  12. package/dist/packages/core/src/infrastructure/HarnessConfigFile.js.map +1 -1
  13. package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js +4 -1
  14. package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js.map +1 -1
  15. package/dist/packages/core/test/unit/rework.test.js +62 -0
  16. package/dist/packages/core/test/unit/rework.test.js.map +1 -0
  17. package/dist/scripts/cli/commands/change/ChangeCommand.js +38 -1
  18. package/dist/scripts/cli/commands/change/ChangeCommand.js.map +1 -1
  19. package/dist/scripts/cli/commands/harness/RunCommand.js +57 -2
  20. package/dist/scripts/cli/commands/harness/RunCommand.js.map +1 -1
  21. package/dist/scripts/cli/commands/spec/DraftCommand.js +44 -4
  22. package/dist/scripts/cli/commands/spec/DraftCommand.js.map +1 -1
  23. package/dist/scripts/cli/commands/spec/StatusCommand.js +5 -1
  24. package/dist/scripts/cli/commands/spec/StatusCommand.js.map +1 -1
  25. package/dist/scripts/cli/commands/spec/StudioCommand.js +194 -1
  26. package/dist/scripts/cli/commands/spec/StudioCommand.js.map +1 -1
  27. package/dist/scripts/docs/record-journey.js +1 -0
  28. package/dist/scripts/docs/record-journey.js.map +1 -1
  29. package/dist/scripts/docs/record-terminal.js +18 -0
  30. package/dist/scripts/docs/record-terminal.js.map +1 -1
  31. package/dist/scripts/draft/drafting-guide.js +11 -1
  32. package/dist/scripts/draft/drafting-guide.js.map +1 -1
  33. package/dist/scripts/lib/change-review.js +145 -0
  34. package/dist/scripts/lib/change-review.js.map +1 -0
  35. package/dist/scripts/lib/clarification.js +24 -5
  36. package/dist/scripts/lib/clarification.js.map +1 -1
  37. package/dist/scripts/lib/harness-guard.js +18 -0
  38. package/dist/scripts/lib/harness-guard.js.map +1 -1
  39. package/dist/scripts/lib/in-flight.js +17 -11
  40. package/dist/scripts/lib/in-flight.js.map +1 -1
  41. package/dist/scripts/lib/live-model.js +7 -1
  42. package/dist/scripts/lib/live-model.js.map +1 -1
  43. package/dist/scripts/lib/live-page.js +72 -11
  44. package/dist/scripts/lib/live-page.js.map +1 -1
  45. package/dist/scripts/lib/screen-requirements.js +149 -0
  46. package/dist/scripts/lib/screen-requirements.js.map +1 -0
  47. package/dist/scripts/lib/studio-app-page.js +960 -0
  48. package/dist/scripts/lib/studio-app-page.js.map +1 -0
  49. package/dist/scripts/lib/studio-app.js +362 -0
  50. package/dist/scripts/lib/studio-app.js.map +1 -0
  51. package/dist/scripts/lib/studio-route.js +933 -0
  52. package/dist/scripts/lib/studio-route.js.map +1 -0
  53. package/package.json +1 -1
  54. package/templates/harness/harness.config.yaml.tpl +10 -1
@@ -0,0 +1,933 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.profilesView = profilesView;
37
+ exports.currentJob = currentJob;
38
+ exports.stopJob = stopJob;
39
+ exports.isProject = isProject;
40
+ exports.isEmptyFolder = isEmptyFolder;
41
+ exports.route = route;
42
+ exports.presetProfiles = presetProfiles;
43
+ exports.agentsInstalled = agentsInstalled;
44
+ exports.writeProfiles = writeProfiles;
45
+ exports.createProject = createProject;
46
+ exports.briefForm = briefForm;
47
+ exports.saveBriefForm = saveBriefForm;
48
+ exports.saveBriefMarkdown = saveBriefMarkdown;
49
+ exports.fillBrief = fillBrief;
50
+ exports.draftsRoute = draftsRoute;
51
+ exports.startDraft = startDraft;
52
+ exports.report = report;
53
+ exports.reportHtml = reportHtml;
54
+ const fs = __importStar(require("node:fs"));
55
+ const os = __importStar(require("node:os"));
56
+ const path = __importStar(require("node:path"));
57
+ const node_child_process_1 = require("node:child_process");
58
+ const YamlLite_1 = require("../../packages/core/src/domain/YamlLite");
59
+ const BriefCards_1 = require("../../packages/core/src/domain/BriefCards");
60
+ const DraftChecklist_1 = require("../../packages/core/src/domain/DraftChecklist");
61
+ const HarnessConfigFile_1 = require("../../packages/core/src/infrastructure/HarnessConfigFile");
62
+ const ChangeWorkspace_1 = require("../../packages/core/src/infrastructure/ChangeWorkspace");
63
+ const brief_check_1 = require("../draft/brief-check");
64
+ const brief_setup_1 = require("../draft/brief-setup");
65
+ const DraftCommand_1 = require("../cli/commands/spec/DraftCommand");
66
+ const clarification_1 = require("./clarification");
67
+ const live_detail_1 = require("./live-detail");
68
+ const live_model_1 = require("./live-model");
69
+ const change_review_1 = require("./change-review");
70
+ const studio_app_1 = require("./studio-app");
71
+ const project_root_1 = require("./project-root");
72
+ /**
73
+ * The studio's route from an empty folder to the report (REQ-211..217). As in
74
+ * studio-app.ts, every read is what a specgate command reads and every write
75
+ * runs the command that makes it.
76
+ */
77
+ const DELIVERED = new Set(["Implemented", "Verified", "Released"]);
78
+ const cli = () => path.join((0, project_root_1.findCliRoot)(__dirname), "bin", "specgate.js");
79
+ const PROFILES = path.join(".harness", "profiles.yaml");
80
+ const JOB = path.join(".harness", "events", "studio-job.json");
81
+ function read(file) {
82
+ try {
83
+ return fs.readFileSync(file, "utf8");
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ function alive(pid) {
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ }
94
+ catch (err) {
95
+ return err.code === "EPERM";
96
+ }
97
+ }
98
+ /** The profile harness.config.yaml names with `agent_profile:`, if any. */
99
+ function defaultProfile(projectDir) {
100
+ const cfg = read(path.join(projectDir, HarnessConfigFile_1.HARNESS_CONFIG_FILE)) || "";
101
+ const m = /^agent_profile:\s*["']?([^"'\s#]+)/m.exec(cfg);
102
+ return m ? m[1] : "";
103
+ }
104
+ /** The profiles a person may pick, the default first marked (REQ-211). */
105
+ function profilesView(projectDir) {
106
+ const text = read(path.join(projectDir, PROFILES));
107
+ const out = [];
108
+ if (text) {
109
+ let doc = {};
110
+ try {
111
+ doc = ((0, YamlLite_1.parseYamlLite)(text) || {});
112
+ }
113
+ catch {
114
+ doc = {};
115
+ }
116
+ const profiles = (doc.profiles || {});
117
+ const chosen = defaultProfile(projectDir);
118
+ for (const [name, p] of Object.entries(profiles)) {
119
+ if (!p || !p.agent || p.advisory === true)
120
+ continue;
121
+ const agent = String(p.agent);
122
+ const hint = Number(p.cost_per_run_hint);
123
+ out.push({
124
+ name,
125
+ agent,
126
+ default: name === chosen,
127
+ costHint: p.cost_per_run_hint !== undefined && Number.isFinite(hint) ? hint : null,
128
+ streams: /harness\s+events\s+--from\s+claude/.test(agent),
129
+ });
130
+ }
131
+ if (out.length && !out.some((p) => p.default))
132
+ out[0].default = true;
133
+ }
134
+ return out;
135
+ }
136
+ /** The arguments that run an agent: the profile picked, else the configured one. */
137
+ function agentArgs(projectDir, profile) {
138
+ if (profile) {
139
+ return profilesView(projectDir).some((p) => p.name === profile) ? ["--profile", profile] : null;
140
+ }
141
+ const chosen = profilesView(projectDir).find((p) => p.default);
142
+ if (chosen)
143
+ return ["--profile", chosen.name];
144
+ const cfg = read(path.join(projectDir, HarnessConfigFile_1.HARNESS_CONFIG_FILE)) || "";
145
+ const m = /^agent:\s*(.+)$/m.exec(cfg);
146
+ if (!m)
147
+ return null;
148
+ const raw = m[1].trim();
149
+ let agent = raw;
150
+ if (raw.startsWith('"')) {
151
+ try {
152
+ agent = JSON.parse(raw);
153
+ }
154
+ catch {
155
+ agent = raw.slice(1, -1);
156
+ }
157
+ }
158
+ else if (raw.startsWith("'"))
159
+ agent = raw.slice(1, -1).replace(/''/g, "'");
160
+ return agent ? ["--agent", agent] : null;
161
+ }
162
+ /** The brief or draft job in progress, if its process is still alive. */
163
+ function currentJob(projectDir) {
164
+ try {
165
+ const job = JSON.parse(fs.readFileSync(path.join(projectDir, JOB), "utf8"));
166
+ return Number.isInteger(job.pid) && job.pid > 0 && alive(job.pid) ? job : null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ /** The last job, alive or not: how it ended is in its log. */
173
+ function lastJob(projectDir) {
174
+ try {
175
+ return JSON.parse(fs.readFileSync(path.join(projectDir, JOB), "utf8"));
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ function startJob(projectDir, kind, module, profile, args) {
182
+ const running = currentJob(projectDir);
183
+ if (running) {
184
+ return {
185
+ ok: false,
186
+ code: "job_running",
187
+ message: running.kind === "draft"
188
+ ? `The module ${running.module} is being drafted. Wait for it, or stop it.`
189
+ : "The agent is working on the brief. Wait for it, or stop it.",
190
+ };
191
+ }
192
+ const dir = path.join(projectDir, ".harness", "events");
193
+ fs.mkdirSync(dir, { recursive: true });
194
+ const ignore = path.join(dir, ".gitignore");
195
+ if (!fs.existsSync(ignore))
196
+ fs.writeFileSync(ignore, "*\n", "utf8");
197
+ const log = path.join(dir, `studio-${kind}.log`);
198
+ fs.writeFileSync(log, "", "utf8");
199
+ const out = fs.openSync(log, "a");
200
+ const child = (0, node_child_process_1.spawn)(process.execPath, [cli(), ...args, "--project-dir", projectDir], {
201
+ cwd: projectDir,
202
+ detached: true,
203
+ stdio: ["ignore", out, out],
204
+ env: {
205
+ ...process.env,
206
+ NO_COLOR: "1",
207
+ // A streaming agent reports what it does; the page shows its last step.
208
+ AGENT_EVENTS_FILE: path.join(dir, `studio-${kind}.jsonl`),
209
+ },
210
+ });
211
+ child.unref();
212
+ fs.closeSync(out);
213
+ const job = {
214
+ kind,
215
+ module,
216
+ profile,
217
+ pid: child.pid || 0,
218
+ started: Math.floor(Date.now() / 1000),
219
+ log: path.relative(projectDir, log).split(path.sep).join("/"),
220
+ };
221
+ fs.writeFileSync(path.join(projectDir, JOB), JSON.stringify(job), "utf8");
222
+ return { ok: true, job };
223
+ }
224
+ /** Stop the brief or draft job in progress, with the processes it started. */
225
+ function stopJob(projectDir) {
226
+ const job = currentJob(projectDir);
227
+ if (!job)
228
+ return { ok: false, code: "job_not_running", message: "Nothing is being drafted." };
229
+ if (process.platform === "win32") {
230
+ (0, node_child_process_1.spawnSync)("taskkill", ["/pid", String(job.pid), "/T", "/F"]);
231
+ }
232
+ else {
233
+ try {
234
+ process.kill(-job.pid, "SIGTERM");
235
+ }
236
+ catch {
237
+ /* already gone */
238
+ }
239
+ }
240
+ return { ok: true };
241
+ }
242
+ /** What the job's agent said last, from its events, for the page. */
243
+ function lastStep(projectDir, kind) {
244
+ const text = read(path.join(projectDir, ".harness", "events", `studio-${kind}.jsonl`)) || "";
245
+ const lines = text.trim().split("\n").reverse();
246
+ for (const l of lines) {
247
+ try {
248
+ const e = JSON.parse(l);
249
+ if (e.type === "tool" && e.summary)
250
+ return String(e.summary);
251
+ if (e.type === "message" && e.text)
252
+ return String(e.text);
253
+ }
254
+ catch {
255
+ /* a line being written */
256
+ }
257
+ }
258
+ return "";
259
+ }
260
+ function isProject(projectDir) {
261
+ return fs.existsSync(path.join(projectDir, "spec.md"));
262
+ }
263
+ /** A folder with nothing in it but, at most, a git repository. */
264
+ function isEmptyFolder(dir) {
265
+ try {
266
+ return fs.readdirSync(dir).filter((f) => f !== ".git").length === 0;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ }
272
+ /** Requirements by id with their status and module, REQ-001 left out as Home does. */
273
+ function requirements(projectDir) {
274
+ const reqs = new Map();
275
+ for (const r of (0, live_detail_1.liveMatrix)(projectDir))
276
+ if (!reqs.has(r.requirement))
277
+ reqs.set(r.requirement, { status: r.status, group: r.group });
278
+ reqs.delete("REQ-001");
279
+ return reqs;
280
+ }
281
+ /** Where the project is on its seven steps, and the next one (REQ-212). */
282
+ function route(projectDir) {
283
+ const project = path.basename(projectDir);
284
+ const keys = ["project", "brief", "drafts", "questions", "review", "build", "report"];
285
+ if (!isProject(projectDir)) {
286
+ return {
287
+ project,
288
+ isProject: false,
289
+ empty: isEmptyFolder(projectDir),
290
+ steps: keys.map((key) => ({
291
+ key,
292
+ state: key === "project" ? "current" : "locked",
293
+ counts: {},
294
+ })),
295
+ next: "project",
296
+ };
297
+ }
298
+ const states = {};
299
+ states.project = { state: "done", counts: {} };
300
+ const briefText = read(path.join(projectDir, DraftCommand_1.DEFAULT_BRIEF));
301
+ const modules = briefText ? (0, DraftChecklist_1.briefModules)(briefText) : [];
302
+ const briefOk = Boolean(briefText) && modules.length > 0 && (0, brief_check_1.briefReady)((0, brief_check_1.checkBrief)(briefText, DraftCommand_1.DEFAULT_BRIEF));
303
+ states.brief = { state: briefOk ? "done" : "open", counts: { modules: modules.length } };
304
+ const statuses = modules.map((m) => (0, DraftCommand_1.moduleStatus)(projectDir, m, briefText || ""));
305
+ const drafted = statuses.filter((s) => s.state === "ready" || s.state === "archived").length;
306
+ const anyDraft = statuses.some((s) => s.state !== "not started") || (0, ChangeWorkspace_1.listChangeIds)(projectDir).length > 0;
307
+ states.drafts = {
308
+ state: !briefOk ? "locked" : drafted === modules.length ? "done" : "open",
309
+ counts: { done: drafted, total: modules.length },
310
+ };
311
+ const questions = (0, clarification_1.waiting)(projectDir).questions.length;
312
+ const reqs = requirements(projectDir);
313
+ const built = [...reqs.values()].filter((r) => DELIVERED.has(r.status)).length;
314
+ const started = anyDraft || reqs.size > 0;
315
+ states.questions = {
316
+ state: !started ? "locked" : questions ? "open" : "done",
317
+ counts: { open: questions },
318
+ };
319
+ const toReview = (0, studio_app_1.drafts)(projectDir).length;
320
+ states.review = {
321
+ state: !started ? "locked" : toReview ? "open" : "done",
322
+ counts: { open: toReview },
323
+ };
324
+ const ready = reqs.size ? (0, studio_app_1.buildView)(projectDir).ready.length : 0;
325
+ states.build = {
326
+ state: reqs.size === 0 ? "locked" : ready ? "open" : built ? "done" : "locked",
327
+ counts: { ready, built, total: reqs.size },
328
+ };
329
+ states.report = { state: built ? "open" : "locked", counts: { built, total: reqs.size } };
330
+ const next = keys.find((k) => states[k].state === "open") || null;
331
+ return {
332
+ project,
333
+ isProject: true,
334
+ empty: false,
335
+ steps: keys.map((key) => ({
336
+ key,
337
+ state: key === next ? "current" : states[key].state,
338
+ counts: states[key].counts,
339
+ })),
340
+ next,
341
+ };
342
+ }
343
+ const STACKS = {
344
+ "node-react": { stack: "Node.js, React", testing: "npm test" },
345
+ "java-spring": {
346
+ stack: "Java 21, Spring Boot 3.x, PostgreSQL",
347
+ testing: "JUnit 5, Testcontainers, Cucumber",
348
+ },
349
+ "python-fastapi": { stack: "Python 3.12, FastAPI, PostgreSQL", testing: "pytest" },
350
+ };
351
+ const CLAUDE = (model) => `claude -p --model ${model} --output-format stream-json --verbose --permission-mode acceptEdits < {prompt_file} | specgate harness events --from claude`;
352
+ /** The profiles a preset writes, the first of them the default. */
353
+ function presetProfiles(agent) {
354
+ if (agent === "claude") {
355
+ return [
356
+ { name: "claude-sonnet", agent: CLAUDE("sonnet") },
357
+ { name: "claude-opus", agent: CLAUDE("opus") },
358
+ ];
359
+ }
360
+ if (agent === "codex")
361
+ return [{ name: "codex", agent: "codex exec --full-auto - < {prompt_file}" }];
362
+ return [];
363
+ }
364
+ /** Which agent CLIs this machine has on its PATH. */
365
+ function agentsInstalled() {
366
+ const has = (cmd) => (0, node_child_process_1.spawnSync)(process.platform === "win32" ? "where" : "which", [cmd], { encoding: "utf8" })
367
+ .status === 0;
368
+ return { claude: has("claude"), codex: has("codex") };
369
+ }
370
+ const slugOf = (name) => name
371
+ .toLowerCase()
372
+ .normalize("NFD")
373
+ .replace(/[̀-ͯ]/g, "")
374
+ .replace(/[^a-z0-9]+/g, "-")
375
+ .replace(/^-|-$/g, "") || "project";
376
+ const yamlValue = (v) => JSON.stringify(v);
377
+ /** Write the profiles and point harness.config.yaml at the first. */
378
+ function writeProfiles(projectDir, profiles) {
379
+ if (profiles.length === 0)
380
+ return;
381
+ const file = path.join(projectDir, PROFILES);
382
+ fs.mkdirSync(path.dirname(file), { recursive: true });
383
+ const lines = ["profiles_version: 1", "profiles:"];
384
+ for (const p of profiles)
385
+ lines.push(` ${p.name}:`, ` agent: ${yamlValue(p.agent)}`);
386
+ fs.writeFileSync(file, lines.join("\n") + "\n", "utf8");
387
+ const cfgFile = path.join(projectDir, HarnessConfigFile_1.HARNESS_CONFIG_FILE);
388
+ const cfg = read(cfgFile) || "harness_version: 1\n";
389
+ const line = `agent_profile: ${profiles[0].name}`;
390
+ const next = /^#?\s*agent_profile:.*$/m.test(cfg)
391
+ ? cfg.replace(/^#?\s*agent_profile:.*$/m, line)
392
+ : `${cfg.trimEnd()}\n${line}\n`;
393
+ fs.writeFileSync(cfgFile, next, "utf8");
394
+ }
395
+ /** Create the project in an empty folder: init, harness, profiles, a brief to fill (REQ-213). */
396
+ function createProject(dir, p) {
397
+ if (isProject(dir)) {
398
+ return {
399
+ ok: false,
400
+ code: "project_exists",
401
+ message: "This folder already holds a specgate project.",
402
+ };
403
+ }
404
+ if (!isEmptyFolder(dir)) {
405
+ return {
406
+ ok: false,
407
+ code: "project_folder_not_empty",
408
+ message: "This folder already holds files. Adopt it from a terminal with `specgate init`, which keeps them.",
409
+ };
410
+ }
411
+ const name = String(p.name || "").trim();
412
+ if (!name)
413
+ return { ok: false, code: "project_no_name", message: "Give the project a name." };
414
+ const preset = p.stack === "custom"
415
+ ? { stack: String(p.customStack || "").trim(), testing: String(p.customTesting || "").trim() }
416
+ : STACKS[p.stack];
417
+ if (!preset || !preset.stack) {
418
+ return { ok: false, code: "project_no_stack", message: "Choose a stack, or write yours." };
419
+ }
420
+ const slug = slugOf(name);
421
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "specgate-new-"));
422
+ try {
423
+ const config = path.join(tmp, "project.yaml");
424
+ fs.writeFileSync(config, [
425
+ `PROJECT_NAME: ${yamlValue(name)}`,
426
+ `PROJECT_SLUG: ${slug}`,
427
+ `PROJECT_TYPE: ${p.kind === "api" ? "backend" : p.kind === "mobile" ? "mobile" : "frontend"}`,
428
+ "DOMAIN: general",
429
+ `STACK: ${yamlValue(preset.stack)}`,
430
+ "API_STYLE: REST with DTO boundaries",
431
+ `TESTING: ${yamlValue(preset.testing || "npm test")}`,
432
+ ].join("\n") + "\n", "utf8");
433
+ const init = (0, node_child_process_1.spawnSync)(process.execPath, [cli(), "init", "--config", config, "--out", tmp, "--new", "--no-git"], { encoding: "utf8", env: { ...process.env, NO_COLOR: "1" } });
434
+ const made = path.join(tmp, slug);
435
+ if (init.status !== 0 || !fs.existsSync(path.join(made, "spec.md"))) {
436
+ return {
437
+ ok: false,
438
+ code: "project_init_failed",
439
+ message: `specgate init failed: ${(init.stderr || init.stdout || "").trim().split("\n").slice(-3).join(" ")}`,
440
+ };
441
+ }
442
+ fs.cpSync(made, dir, { recursive: true });
443
+ }
444
+ finally {
445
+ fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 3 });
446
+ }
447
+ const run = (args) => (0, node_child_process_1.spawnSync)(process.execPath, [cli(), ...args, "--project-dir", dir], {
448
+ cwd: dir,
449
+ encoding: "utf8",
450
+ env: { ...process.env, NO_COLOR: "1" },
451
+ });
452
+ run(["harness", "init"]);
453
+ writeProfiles(dir, presetProfiles(p.agent));
454
+ // A brief with nothing to delete: the studio's form fills it, so the
455
+ // template's examples and blanks would only be in the way.
456
+ const brief = path.join(dir, DraftCommand_1.DEFAULT_BRIEF);
457
+ if (!fs.existsSync(brief)) {
458
+ const surface = p.kind === "api" ? "api" : p.kind === "mobile" ? "mobile" : "web";
459
+ fs.mkdirSync(path.dirname(brief), { recursive: true });
460
+ fs.writeFileSync(brief, `---\nproduct: ${name}\nsurfaces: [${surface}]\nactors:\nmodules: []\n---\n\n# ${name}\n`, "utf8");
461
+ }
462
+ // The harness needs a clean tree and a first commit to branch from.
463
+ if (!fs.existsSync(path.join(dir, ".git")))
464
+ (0, node_child_process_1.spawnSync)("git", ["init", "-q"], { cwd: dir });
465
+ (0, node_child_process_1.spawnSync)("git", ["add", "-A"], { cwd: dir });
466
+ (0, node_child_process_1.spawnSync)("git", ["commit", "-q", "-m", "chore: start the project with specgate"], { cwd: dir });
467
+ return { ok: true, dir };
468
+ }
469
+ function findingsOf(text) {
470
+ return (0, brief_check_1.checkBrief)(text, DraftCommand_1.DEFAULT_BRIEF).map((d) => ({
471
+ severity: d.severity,
472
+ code: d.code,
473
+ message: d.message,
474
+ fix: d.fix || "",
475
+ }));
476
+ }
477
+ /** docs/brief.md read as the form the studio edits (REQ-214). */
478
+ function briefForm(projectDir) {
479
+ const text = read(path.join(projectDir, DraftCommand_1.DEFAULT_BRIEF));
480
+ if (text === undefined) {
481
+ return { exists: false, product: "", actors: [], modules: [], markdown: "", findings: [] };
482
+ }
483
+ const fm = (0, brief_setup_1.frontMatter)(text) || "";
484
+ const product = (/^product:[ \t]*(.*)$/m.exec(fm) || ["", ""])[1]
485
+ .trim()
486
+ .replace(/^["']|["']$/g, "");
487
+ // As written: actorSurfaces lowercases the names it compares.
488
+ const actors = [...fm.matchAll(/\{\s*name:\s*([^,}]+?)\s*,\s*surfaces:\s*\[([^\]]*)\]/g)]
489
+ .map((m) => ({
490
+ name: m[1].replace(/^["']|["']$/g, ""),
491
+ surfaces: m[2]
492
+ .split(",")
493
+ .map((s) => s.trim())
494
+ .filter(Boolean),
495
+ }))
496
+ .filter((a) => !a.name.includes("<"));
497
+ // A template's `## Module: <first-module>` is a blank, not a module.
498
+ const sections = new Map((0, BriefCards_1.readBriefBody)(text)
499
+ .modules.filter((m) => !m.name.startsWith("<"))
500
+ .map((m) => [(0, BriefCards_1.moduleKey)(m.name), m]));
501
+ const keys = (0, DraftChecklist_1.briefModules)(text);
502
+ for (const k of sections.keys())
503
+ if (!keys.includes(k))
504
+ keys.push(k);
505
+ const modules = keys.map((key) => {
506
+ const s = sections.get(key);
507
+ return {
508
+ key,
509
+ name: s ? s.name : key,
510
+ prose: s ? s.prose : "",
511
+ rules: s ? s.rules.map((c) => c.text) : [],
512
+ examples: s ? s.examples.map((c) => c.text) : [],
513
+ questions: s ? s.questions.map((c) => c.text) : [],
514
+ };
515
+ });
516
+ return { exists: true, product, actors, modules, markdown: text, findings: findingsOf(text) };
517
+ }
518
+ /** Replace a top-level front matter field and its indented lines, or add it. */
519
+ function setField(fm, name, block) {
520
+ const lines = fm.split("\n");
521
+ const at = lines.findIndex((l) => new RegExp(`^${name}:`).test(l));
522
+ if (at < 0)
523
+ return `${fm.trimEnd()}\n${block}`;
524
+ let end = at + 1;
525
+ while (end < lines.length && /^[ \t]+\S/.test(lines[end]))
526
+ end += 1;
527
+ // Comments indented under the field are the author's notes on it: kept.
528
+ const notes = lines.slice(at + 1, end).filter((l) => /^[ \t]+#/.test(l));
529
+ const [head, ...rest] = block.split("\n");
530
+ return [...lines.slice(0, at), head, ...notes, ...rest, ...lines.slice(end)].join("\n");
531
+ }
532
+ function moduleSectionText(form, cards) {
533
+ const list = (items) => items.map((t) => `- ${t.trim()}`).join("\n");
534
+ // A card the form left empty is not a card.
535
+ const filled = (items) => (items || []).filter((t) => String(t).trim());
536
+ const m = {
537
+ ...form,
538
+ prose: form.prose || "",
539
+ rules: filled(form.rules),
540
+ examples: filled(form.examples),
541
+ questions: filled(form.questions),
542
+ };
543
+ const parts = [`## Module: ${m.name.trim() || m.key}`];
544
+ if (m.prose.trim())
545
+ parts.push(m.prose.trim());
546
+ if (m.rules.length)
547
+ parts.push(`${cards.rules}\n\n${list(m.rules)}`);
548
+ if (m.examples.length)
549
+ parts.push(`${cards.examples}\n\n${list(m.examples)}`);
550
+ if (m.questions.length)
551
+ parts.push(`${cards.questions}\n\n${list(m.questions)}`);
552
+ return parts.join("\n\n");
553
+ }
554
+ /** The form written back into docs/brief.md, keeping every other line (REQ-214). */
555
+ function saveBriefForm(projectDir, form) {
556
+ const file = path.join(projectDir, DraftCommand_1.DEFAULT_BRIEF);
557
+ const text = (read(file) || "").replace(/\r\n/g, "\n");
558
+ const fmMatch = /^---\n([\s\S]*?)\n---[ \t]*\n?/.exec(text);
559
+ let fm = fmMatch ? fmMatch[1] : "";
560
+ let body = fmMatch ? text.slice(fmMatch[0].length) : text;
561
+ const modules = form.modules.filter((m) => (m.name || m.key).trim());
562
+ const keys = modules.map((m) => (0, BriefCards_1.moduleKey)(m.key || m.name));
563
+ const actors = form.actors.filter((a) => a.name.trim());
564
+ const surfaces = [...new Set(actors.flatMap((a) => a.surfaces))].filter((s) => !s.includes("<"));
565
+ fm = setField(fm, "product", `product: ${form.product.trim()}`);
566
+ fm = setField(fm, "surfaces", `surfaces: [${surfaces.join(", ")}]`);
567
+ fm = setField(fm, "actors", [
568
+ "actors:",
569
+ ...actors.map((a) => ` - { name: ${a.name.trim()}, surfaces: [${a.surfaces.join(", ")}] }`),
570
+ ].join("\n"));
571
+ fm = setField(fm, "modules", `modules: [${keys.join(", ")}]`);
572
+ const sections = (0, BriefCards_1.readBriefBody)(text).modules;
573
+ const spanish = sections.some((s) => /^\s*\**reglas/im.test(s.source));
574
+ const cards = spanish
575
+ ? { rules: "Reglas", examples: "Ejemplos", questions: "Preguntas" }
576
+ : { rules: "Rules", examples: "Examples", questions: "Questions" };
577
+ const byKey = new Map(sections.map((s) => [(0, BriefCards_1.moduleKey)(s.name), s]));
578
+ for (const s of sections) {
579
+ if (!keys.includes((0, BriefCards_1.moduleKey)(s.name)))
580
+ body = body.replace(s.source, "").replace(/\n{3,}/g, "\n\n");
581
+ }
582
+ for (const m of modules) {
583
+ const key = (0, BriefCards_1.moduleKey)(m.key || m.name);
584
+ const existing = byKey.get(key);
585
+ const hasCards = m.rules.length || m.examples.length || m.questions.length || m.prose.trim();
586
+ if (existing)
587
+ body = body.replace(existing.source, moduleSectionText(m, cards));
588
+ else if (hasCards)
589
+ body = `${body.trimEnd()}\n\n${moduleSectionText(m, cards)}\n`;
590
+ }
591
+ const out = `---\n${fm.replace(/^\n+/, "")}\n---\n${body.startsWith("\n") ? "" : "\n"}${body.trimEnd()}\n`;
592
+ fs.mkdirSync(path.dirname(file), { recursive: true });
593
+ fs.writeFileSync(file, out, "utf8");
594
+ return briefForm(projectDir);
595
+ }
596
+ /** The brief saved as markdown, as written (REQ-214). */
597
+ function saveBriefMarkdown(projectDir, markdown) {
598
+ const file = path.join(projectDir, DraftCommand_1.DEFAULT_BRIEF);
599
+ fs.mkdirSync(path.dirname(file), { recursive: true });
600
+ fs.writeFileSync(file, String(markdown).replace(/\r\n/g, "\n"), "utf8");
601
+ return briefForm(projectDir);
602
+ }
603
+ /** A client's text saved as the brief, and the agent asked to structure it (REQ-215). */
604
+ function fillBrief(projectDir, text, profile, replace = false) {
605
+ const file = path.join(projectDir, DraftCommand_1.DEFAULT_BRIEF);
606
+ if (fs.existsSync(file) && !replace) {
607
+ return {
608
+ ok: false,
609
+ code: "brief_exists",
610
+ message: "There is a brief already. Replace it, or edit it in the form.",
611
+ };
612
+ }
613
+ if (!String(text || "").trim()) {
614
+ return { ok: false, code: "brief_empty", message: "Paste the client's text first." };
615
+ }
616
+ const agent = agentArgs(projectDir, profile);
617
+ if (!agent) {
618
+ return {
619
+ ok: false,
620
+ code: "brief_no_agent",
621
+ message: "No agent is configured to read the text.",
622
+ };
623
+ }
624
+ if (currentJob(projectDir)) {
625
+ return {
626
+ ok: false,
627
+ code: "job_running",
628
+ message: "The agent is busy. Wait for it, or stop it.",
629
+ };
630
+ }
631
+ fs.mkdirSync(path.dirname(file), { recursive: true });
632
+ fs.writeFileSync(file, String(text).replace(/\r\n/g, "\n"), "utf8");
633
+ return startJob(projectDir, "brief", "", agent[1], ["propose", "--from-brief", ...agent]);
634
+ }
635
+ function deltaCounts(projectDir, change) {
636
+ const dir = path.join(projectDir, "docs", "specs", "changes", change, "specs");
637
+ let requirements = 0;
638
+ let scenarios = 0;
639
+ const walk = (d) => {
640
+ let entries = [];
641
+ try {
642
+ entries = fs.readdirSync(d, { withFileTypes: true });
643
+ }
644
+ catch {
645
+ return;
646
+ }
647
+ for (const e of entries) {
648
+ const full = path.join(d, e.name);
649
+ if (e.isDirectory())
650
+ walk(full);
651
+ else if (e.name.endsWith(".md")) {
652
+ const t = read(full) || "";
653
+ requirements += (t.match(/^###\s+Requirement:\s*REQ-\d+/gm) || []).length;
654
+ scenarios += (t.match(/^####\s+Scenario:\s*SCN-/gm) || []).length;
655
+ }
656
+ }
657
+ };
658
+ walk(dir);
659
+ return { requirements, scenarios };
660
+ }
661
+ /** Each module of the brief with where it stands, and the job drafting one (REQ-216). */
662
+ function draftsRoute(projectDir) {
663
+ const briefText = read(path.join(projectDir, DraftCommand_1.DEFAULT_BRIEF));
664
+ const job = currentJob(projectDir);
665
+ const ended = job ? null : lastJob(projectDir);
666
+ const step = job ? lastStep(projectDir, job.kind) : "";
667
+ if (!briefText)
668
+ return { brief: false, modules: [], job, step, lastJob: null };
669
+ const open = (0, clarification_1.waiting)(projectDir).questions;
670
+ // An archived draft's requirements live in the specification now, by module.
671
+ const delivered = new Map();
672
+ for (const r of (0, live_detail_1.liveMatrix)(projectDir)) {
673
+ if (r.source !== "spec")
674
+ continue;
675
+ const g = delivered.get(r.group) || { requirements: new Set(), scenarios: 0 };
676
+ g.requirements.add(r.requirement);
677
+ g.scenarios += r.scenario ? 1 : 0;
678
+ delivered.set(r.group, g);
679
+ }
680
+ const modules = (0, DraftChecklist_1.briefModules)(briefText).map((key) => {
681
+ const s = (0, DraftCommand_1.moduleStatus)(projectDir, key, briefText);
682
+ const inSpec = delivered.get(key);
683
+ const counts = s.state === "archived" && inSpec
684
+ ? { requirements: inSpec.requirements.size, scenarios: inSpec.scenarios }
685
+ : deltaCounts(projectDir, s.change);
686
+ const questions = open.filter((q) => q.change === s.change).length;
687
+ const drafting = job && job.kind === "draft" && job.module === key;
688
+ let findings = [];
689
+ if (s.state === "drafting" || s.state === "ready") {
690
+ const r = (0, DraftCommand_1.draftReport)(projectDir, s.change, briefText);
691
+ if (!("failure" in r)) {
692
+ findings = r.report.status.map((d) => ({
693
+ severity: d.severity,
694
+ code: d.code,
695
+ message: d.message,
696
+ }));
697
+ }
698
+ }
699
+ const stage = drafting
700
+ ? "drafting"
701
+ : s.state === "archived"
702
+ ? "archived"
703
+ : s.state === "not started" || (s.state === "not written" && counts.requirements === 0)
704
+ ? s.state === "not written" && ended && ended.kind === "draft" && ended.module === key
705
+ ? "stopped"
706
+ : "todo"
707
+ : (0, change_review_1.approvedOn)(projectDir, s.change)
708
+ ? "approved"
709
+ : "review";
710
+ return {
711
+ key,
712
+ stage,
713
+ ...counts,
714
+ questions,
715
+ findings,
716
+ progress: drafting
717
+ ? {
718
+ seconds: Math.floor(Date.now() / 1000) - job.started,
719
+ ...counts,
720
+ notes: fs.existsSync(path.join(projectDir, "docs", "specs", "changes", s.change, "questions.md")) ||
721
+ fs.existsSync(path.join(projectDir, "docs", "specs", "changes", s.change, "assumptions.md")),
722
+ lastStep: lastStep(projectDir, "draft"),
723
+ }
724
+ : null,
725
+ };
726
+ });
727
+ const tail = ended
728
+ ? (read(path.join(projectDir, ended.log)) || "").trim().split("\n").slice(-4).join("\n")
729
+ : "";
730
+ return {
731
+ brief: true,
732
+ modules,
733
+ job,
734
+ step,
735
+ lastJob: ended ? { kind: ended.kind, module: ended.module, log: ended.log, tail } : null,
736
+ };
737
+ }
738
+ /** Draft a module with the agent picked, in the background (REQ-216). */
739
+ function startDraft(projectDir, module, profile) {
740
+ const briefText = read(path.join(projectDir, DraftCommand_1.DEFAULT_BRIEF));
741
+ if (!briefText || !(0, DraftChecklist_1.briefModules)(briefText).includes(module)) {
742
+ return {
743
+ ok: false,
744
+ code: "draft_unknown_module",
745
+ message: `The brief has no module ${module}.`,
746
+ };
747
+ }
748
+ const agent = agentArgs(projectDir, profile);
749
+ if (!agent) {
750
+ return {
751
+ ok: false,
752
+ code: "draft_no_agent",
753
+ message: profile
754
+ ? `No profile ${profile} in .harness/profiles.yaml.`
755
+ : "No agent is configured.",
756
+ };
757
+ }
758
+ return startJob(projectDir, "draft", module, agent[0] === "--profile" ? agent[1] : "", [
759
+ "propose",
760
+ "--from-brief",
761
+ module,
762
+ ...agent,
763
+ ]);
764
+ }
765
+ /** Every scenario's title, by id, from the specification's files. */
766
+ function scenarioTitles(projectDir) {
767
+ const titles = new Map();
768
+ const files = [path.join(projectDir, "spec.md")];
769
+ const walk = (d) => {
770
+ let entries = [];
771
+ try {
772
+ entries = fs.readdirSync(d, { withFileTypes: true });
773
+ }
774
+ catch {
775
+ return;
776
+ }
777
+ for (const e of entries) {
778
+ const full = path.join(d, e.name);
779
+ if (e.isDirectory())
780
+ walk(full);
781
+ else if (e.name.endsWith(".md"))
782
+ files.push(full);
783
+ }
784
+ };
785
+ walk(path.join(projectDir, "docs", "specs"));
786
+ for (const f of files) {
787
+ for (const m of (read(f) || "").matchAll(/^#{3,5}\s+Scenario:\s*(SCN-[\w.]+)\s*[—–-]+\s*(.+)$/gm)) {
788
+ if (!titles.has(m[1]))
789
+ titles.set(m[1], m[2].trim());
790
+ }
791
+ }
792
+ return titles;
793
+ }
794
+ /** What was built, how it was proven, and what is missing (REQ-217). */
795
+ function report(projectDir, withGate = true) {
796
+ const matrix = (0, live_detail_1.liveMatrix)(projectDir).filter((r) => r.requirement !== "REQ-001");
797
+ const titles = scenarioTitles(projectDir);
798
+ const byReq = new Map();
799
+ for (const r of matrix) {
800
+ const e = byReq.get(r.requirement) || {
801
+ title: r.title,
802
+ group: r.group,
803
+ status: r.status,
804
+ scenarios: [],
805
+ };
806
+ if (r.scenario && !e.scenarios.includes(r.scenario))
807
+ e.scenarios.push(r.scenario);
808
+ byReq.set(r.requirement, e);
809
+ }
810
+ const questions = (0, clarification_1.allQuestions)(projectDir);
811
+ const holding = new Map();
812
+ for (const q of questions.filter((q) => !q.answered))
813
+ for (const b of q.blocks)
814
+ holding.set(b, q.text);
815
+ const delivered = [];
816
+ const missing = [];
817
+ const modules = new Map();
818
+ for (const [id, r] of byReq) {
819
+ const name = r.group || "spec";
820
+ const m = modules.get(name) || { name, built: 0, total: 0, waiting: 0, scenarios: 0 };
821
+ m.total += 1;
822
+ if (DELIVERED.has(r.status)) {
823
+ m.built += 1;
824
+ m.scenarios += r.scenarios.length;
825
+ delivered.push({
826
+ id,
827
+ title: r.title,
828
+ module: name,
829
+ scenarios: r.scenarios.map((s) => ({ id: s, title: titles.get(s) || "" })),
830
+ });
831
+ }
832
+ else {
833
+ const waitingFor = r.status === "Needs Clarification" || holding.has(id);
834
+ if (waitingFor)
835
+ m.waiting += 1;
836
+ missing.push({
837
+ id,
838
+ title: r.title,
839
+ module: name,
840
+ why: waitingFor ? "waiting" : "not built",
841
+ question: holding.get(id) || "",
842
+ });
843
+ }
844
+ modules.set(name, m);
845
+ }
846
+ let minutes = 0;
847
+ const runs = path.join(projectDir, ".harness", "runs");
848
+ let files = [];
849
+ try {
850
+ files = fs.readdirSync(runs).filter((f) => f.endsWith(".json"));
851
+ }
852
+ catch {
853
+ /* no runs */
854
+ }
855
+ for (const f of files) {
856
+ try {
857
+ const doc = JSON.parse(fs.readFileSync(path.join(runs, f), "utf8"));
858
+ for (const r of doc.results || [])
859
+ minutes += Number(r.durationMs) > 0 ? Number(r.durationMs) / 60000 : 0;
860
+ }
861
+ catch {
862
+ /* a record being written */
863
+ }
864
+ }
865
+ const costs = (0, live_model_1.readLive)(projectDir)
866
+ .requirements.map((r) => (r.usage && r.usage.cost !== null ? r.usage.cost : null))
867
+ .filter((c) => c !== null);
868
+ let assumptions = 0;
869
+ const countRows = (file) => ((read(file) || "").match(/^\|\s*A\d+\s*\|/gm) || []).length;
870
+ assumptions += countRows(path.join(projectDir, "docs", "specs", "assumptions.md"));
871
+ for (const id of (0, ChangeWorkspace_1.listArchivedIds)(projectDir)) {
872
+ assumptions += countRows(path.join(projectDir, "docs", "specs", "changes", "archive", id, "assumptions.md"));
873
+ }
874
+ return {
875
+ project: path.basename(projectDir),
876
+ date: new Date().toISOString().slice(0, 10),
877
+ built: delivered.length,
878
+ total: byReq.size,
879
+ scenarios: delivered.reduce((n, d) => n + d.scenarios.length, 0),
880
+ gate: withGate ? (0, studio_app_1.gate)(projectDir) : { ok: true, findings: [] },
881
+ cost: costs.length ? Math.round(costs.reduce((a, b) => a + b, 0) * 100) / 100 : null,
882
+ minutes: Math.round(minutes),
883
+ modules: [...modules.values()].sort((a, b) => a.name.localeCompare(b.name)),
884
+ delivered,
885
+ missing,
886
+ answered: questions.filter((q) => q.answered).length,
887
+ assumptions,
888
+ };
889
+ }
890
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
891
+ /** The report as one HTML page that opens on its own (REQ-217). */
892
+ function reportHtml(r) {
893
+ const rows = r.modules
894
+ .map((m) => `<tr><td>${esc(m.name)}</td><td>${m.built} / ${m.total}</td><td>${m.scenarios}</td><td>${m.waiting ? `${m.waiting} waiting` : m.built === m.total ? "complete" : ""}</td></tr>`)
895
+ .join("");
896
+ const delivered = r.delivered
897
+ .map((d) => `<li><strong>${esc(d.id)} — ${esc(d.title)}</strong> <span class="m">${esc(d.module)}</span><ul>${d.scenarios
898
+ .map((s) => `<li>${esc(s.id)} ${esc(s.title)}</li>`)
899
+ .join("")}</ul></li>`)
900
+ .join("");
901
+ const missing = r.missing
902
+ .map((m) => `<li>${esc(m.id)} — ${esc(m.title)} <span class="m">${esc(m.module)} · ${m.why === "waiting" ? `waiting: ${esc(m.question)}` : "not built"}</span></li>`)
903
+ .join("");
904
+ return `<!doctype html>
905
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
906
+ <title>${esc(r.project)} — report</title>
907
+ <style>
908
+ body{margin:0;background:#f6f8fa;color:#1f2328;font:14px/1.5 -apple-system,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif}
909
+ main{max-width:960px;margin:0 auto;padding:28px 16px}
910
+ h1{font-size:26px;margin:0 0 4px}h2{font-size:16px;margin:0 0 10px}
911
+ .k{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:20px 0}
912
+ .c,section{background:#fff;border:1px solid #d1d9e0;border-radius:8px;padding:16px 18px;margin-bottom:16px}
913
+ .c b{display:block;font-size:24px}.m{color:#59636e;font-size:13px}
914
+ table{width:100%;border-collapse:collapse}td,th{text-align:left;padding:8px;border-top:1px solid #d1d9e0}
915
+ ul{padding-left:18px}
916
+ </style></head><body><main>
917
+ <p class="m">${esc(r.date)}</p>
918
+ <h1>${esc(r.project)}</h1>
919
+ <p class="m">Built from the specification and its tests with specgate.</p>
920
+ <div class="k">
921
+ <div class="c">Built<b>${r.built} / ${r.total}</b></div>
922
+ <div class="c">Examples proven<b>${r.scenarios}</b></div>
923
+ <div class="c">Gate<b>${r.gate.ok ? "passes" : "fails"}</b></div>
924
+ <div class="c">Agent<b>${r.cost === null ? "—" : `$${r.cost.toFixed(2)}`}</b><span class="m">${r.minutes} min</span></div>
925
+ </div>
926
+ <section><h2>By module</h2><table><tr><th>Module</th><th>Built</th><th>Examples</th><th></th></tr>${rows}</table></section>
927
+ <section><h2>What the app does</h2><ul>${delivered || "<li>Nothing built yet.</li>"}</ul></section>
928
+ <section><h2>What is missing</h2><ul>${missing || "<li>Nothing.</li>"}</ul></section>
929
+ <section><h2>Decisions</h2><p>${r.answered} questions answered · ${r.assumptions} assumptions recorded.</p></section>
930
+ </main></body></html>
931
+ `;
932
+ }
933
+ //# sourceMappingURL=studio-route.js.map