@specmarket/cli 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/{chunk-MS2DYACY.js → chunk-OTXWWFAO.js} +42 -3
- package/dist/chunk-OTXWWFAO.js.map +1 -0
- package/dist/{config-R5KWZSJP.js → config-5JMI3YAR.js} +2 -2
- package/dist/index.js +1945 -252
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/comment.test.ts +211 -0
- package/src/commands/comment.ts +176 -0
- package/src/commands/fork.test.ts +163 -0
- package/src/commands/info.test.ts +192 -0
- package/src/commands/info.ts +66 -2
- package/src/commands/init.test.ts +245 -0
- package/src/commands/init.ts +359 -25
- package/src/commands/issues.test.ts +382 -0
- package/src/commands/issues.ts +436 -0
- package/src/commands/login.test.ts +99 -0
- package/src/commands/login.ts +2 -6
- package/src/commands/logout.test.ts +54 -0
- package/src/commands/publish.test.ts +159 -0
- package/src/commands/publish.ts +1 -0
- package/src/commands/report.test.ts +181 -0
- package/src/commands/run.test.ts +419 -0
- package/src/commands/run.ts +71 -3
- package/src/commands/search.test.ts +147 -0
- package/src/commands/validate.test.ts +206 -2
- package/src/commands/validate.ts +315 -192
- package/src/commands/whoami.test.ts +106 -0
- package/src/index.ts +6 -0
- package/src/lib/convex-client.ts +6 -2
- package/src/lib/format-detection.test.ts +223 -0
- package/src/lib/format-detection.ts +172 -0
- package/src/lib/meta-instructions.test.ts +340 -0
- package/src/lib/meta-instructions.ts +562 -0
- package/src/lib/ralph-loop.test.ts +404 -0
- package/src/lib/ralph-loop.ts +501 -95
- package/src/lib/telemetry.ts +7 -1
- package/dist/chunk-MS2DYACY.js.map +0 -1
- /package/dist/{config-R5KWZSJP.js.map → config-5JMI3YAR.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
import {
|
|
3
3
|
CONFIG_PATHS,
|
|
4
4
|
DEFAULT_CONVEX_URL,
|
|
5
|
+
DEFAULT_HARNESS,
|
|
6
|
+
DEFAULT_WEB_URL,
|
|
5
7
|
EXIT_CODES,
|
|
8
|
+
KNOWN_HARNESSES,
|
|
9
|
+
MODEL_COST_PER_TOKEN,
|
|
6
10
|
REQUIRED_SPEC_FILES,
|
|
7
11
|
REQUIRED_STDLIB_FILES,
|
|
8
12
|
RUN_DEFAULTS,
|
|
13
|
+
SIDECAR_FILENAME,
|
|
9
14
|
TOKEN_EXPIRY_MS,
|
|
10
15
|
loadConfig,
|
|
11
16
|
saveConfig,
|
|
12
17
|
specYamlSchema,
|
|
18
|
+
specmarketSidecarSchema,
|
|
13
19
|
transformInfrastructure
|
|
14
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-OTXWWFAO.js";
|
|
15
21
|
import {
|
|
16
22
|
api
|
|
17
23
|
} from "./chunk-JEUDDJP7.js";
|
|
@@ -97,6 +103,11 @@ var debug2 = createDebug2("specmarket:convex");
|
|
|
97
103
|
async function getConvexClient(token) {
|
|
98
104
|
const config = await loadConfig();
|
|
99
105
|
const url = process.env["CONVEX_URL"] ?? config.convexUrl ?? DEFAULT_CONVEX_URL;
|
|
106
|
+
if (url.includes("placeholder.convex.cloud")) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"CONVEX_URL is not configured. Set the CONVEX_URL environment variable or run `specmarket config set convexUrl <url>`."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
100
111
|
debug2("Creating Convex client for URL: %s", url);
|
|
101
112
|
const client = new ConvexHttpClient(url);
|
|
102
113
|
if (token) {
|
|
@@ -162,9 +173,7 @@ async function handleTokenLogin(token) {
|
|
|
162
173
|
}
|
|
163
174
|
}
|
|
164
175
|
async function handleDeviceCodeLogin() {
|
|
165
|
-
const
|
|
166
|
-
const baseUrl = config.convexUrl ?? process.env["CONVEX_URL"] ?? "https://your-deployment.convex.cloud";
|
|
167
|
-
const webUrl = baseUrl.replace("convex.cloud", "specmarket.dev");
|
|
176
|
+
const webUrl = DEFAULT_WEB_URL;
|
|
168
177
|
const client = await getConvexClient();
|
|
169
178
|
let api2;
|
|
170
179
|
try {
|
|
@@ -339,10 +348,177 @@ function createWhoamiCommand() {
|
|
|
339
348
|
import { Command as Command4 } from "commander";
|
|
340
349
|
import chalk4 from "chalk";
|
|
341
350
|
import ora2 from "ora";
|
|
342
|
-
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
343
|
-
import { join as
|
|
351
|
+
import { mkdir as mkdir2, writeFile as writeFile2, readdir as readdir2 } from "fs/promises";
|
|
352
|
+
import { join as join3, resolve, basename } from "path";
|
|
344
353
|
import createDebug4 from "debug";
|
|
354
|
+
|
|
355
|
+
// src/lib/format-detection.ts
|
|
356
|
+
import { readFile as readFile2, readdir, access } from "fs/promises";
|
|
357
|
+
import { join as join2 } from "path";
|
|
358
|
+
import { parse as parseYaml } from "yaml";
|
|
359
|
+
async function fileExists(filePath) {
|
|
360
|
+
try {
|
|
361
|
+
await access(filePath);
|
|
362
|
+
return true;
|
|
363
|
+
} catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function directoryExists(dirPath) {
|
|
368
|
+
try {
|
|
369
|
+
await access(dirPath);
|
|
370
|
+
return true;
|
|
371
|
+
} catch {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async function hasStoryFiles(dir) {
|
|
376
|
+
try {
|
|
377
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
378
|
+
return entries.some(
|
|
379
|
+
(e) => e.isFile() && e.name.startsWith("story-") && e.name.endsWith(".md")
|
|
380
|
+
);
|
|
381
|
+
} catch {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function hasMarkdownFiles(dir) {
|
|
386
|
+
try {
|
|
387
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
388
|
+
for (const entry of entries) {
|
|
389
|
+
if (entry.isFile() && entry.name.endsWith(".md")) return true;
|
|
390
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
391
|
+
const found = await hasMarkdownFiles(join2(dir, entry.name));
|
|
392
|
+
if (found) return true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
} catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function tryReadSidecar(dir) {
|
|
401
|
+
const path = join2(dir, SIDECAR_FILENAME);
|
|
402
|
+
if (!await fileExists(path)) return null;
|
|
403
|
+
try {
|
|
404
|
+
const raw = await readFile2(path, "utf-8");
|
|
405
|
+
const parsed = parseYaml(raw);
|
|
406
|
+
if (parsed && typeof parsed === "object" && "spec_format" in parsed) {
|
|
407
|
+
const fmt = parsed.spec_format;
|
|
408
|
+
if (typeof fmt === "string" && fmt.length > 0) return { spec_format: fmt };
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async function detectSpecFormat(dir) {
|
|
416
|
+
const sidecar = await tryReadSidecar(dir);
|
|
417
|
+
if (sidecar) {
|
|
418
|
+
return {
|
|
419
|
+
format: sidecar.spec_format,
|
|
420
|
+
detectedBy: "sidecar",
|
|
421
|
+
confidence: "high"
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const hasSpecYaml = await fileExists(join2(dir, "spec.yaml"));
|
|
425
|
+
const hasPromptMd = await fileExists(join2(dir, "PROMPT.md"));
|
|
426
|
+
const hasSuccessCriteria = await fileExists(join2(dir, "SUCCESS_CRITERIA.md"));
|
|
427
|
+
if (hasSpecYaml && hasPromptMd && hasSuccessCriteria) {
|
|
428
|
+
return {
|
|
429
|
+
format: "specmarket",
|
|
430
|
+
detectedBy: "heuristic",
|
|
431
|
+
confidence: "high"
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (hasPromptMd && hasSuccessCriteria) {
|
|
435
|
+
return {
|
|
436
|
+
format: "specmarket",
|
|
437
|
+
detectedBy: "heuristic",
|
|
438
|
+
confidence: "high"
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const hasSpecMd = await fileExists(join2(dir, "spec.md"));
|
|
442
|
+
const hasPlanMd = await fileExists(join2(dir, "plan.md"));
|
|
443
|
+
const hasTasksMd = await fileExists(join2(dir, "tasks.md"));
|
|
444
|
+
if (hasSpecMd && (hasPlanMd || hasTasksMd)) {
|
|
445
|
+
return {
|
|
446
|
+
format: "speckit",
|
|
447
|
+
detectedBy: "heuristic",
|
|
448
|
+
confidence: "high"
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
if (hasSpecMd) {
|
|
452
|
+
return {
|
|
453
|
+
format: "speckit",
|
|
454
|
+
detectedBy: "heuristic",
|
|
455
|
+
confidence: "high"
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const hasPrdMd = await fileExists(join2(dir, "prd.md"));
|
|
459
|
+
const hasArchitectureMd = await fileExists(join2(dir, "architecture.md"));
|
|
460
|
+
const storyFiles = await hasStoryFiles(dir);
|
|
461
|
+
if (hasPrdMd && (hasArchitectureMd || storyFiles)) {
|
|
462
|
+
return {
|
|
463
|
+
format: "bmad",
|
|
464
|
+
detectedBy: "heuristic",
|
|
465
|
+
confidence: "high"
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const prdJsonPath = join2(dir, "prd.json");
|
|
469
|
+
if (await fileExists(prdJsonPath)) {
|
|
470
|
+
try {
|
|
471
|
+
const raw = await readFile2(prdJsonPath, "utf-8");
|
|
472
|
+
const data = JSON.parse(raw);
|
|
473
|
+
if (data && typeof data === "object") {
|
|
474
|
+
return {
|
|
475
|
+
format: "ralph",
|
|
476
|
+
detectedBy: "heuristic",
|
|
477
|
+
confidence: "high"
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (await hasMarkdownFiles(dir)) {
|
|
484
|
+
return {
|
|
485
|
+
format: "custom",
|
|
486
|
+
detectedBy: "heuristic",
|
|
487
|
+
confidence: "low"
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
format: "custom",
|
|
492
|
+
detectedBy: "heuristic",
|
|
493
|
+
confidence: "low"
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// src/commands/init.ts
|
|
345
498
|
var debug4 = createDebug4("specmarket:cli");
|
|
499
|
+
function buildSpecmarketYaml(data) {
|
|
500
|
+
return `# SpecMarket metadata (required for validate/publish)
|
|
501
|
+
# Single source of truth for format and marketplace fields.
|
|
502
|
+
# Your existing spec files (Spec Kit, BMAD, Ralph, etc.) are not modified.
|
|
503
|
+
|
|
504
|
+
spec_format: ${data.specFormat}
|
|
505
|
+
display_name: "${data.displayName.replace(/"/g, '\\"')}"
|
|
506
|
+
description: "${data.description.replace(/"/g, '\\"')}"
|
|
507
|
+
output_type: ${data.outputType}
|
|
508
|
+
primary_stack: ${data.primaryStack}
|
|
509
|
+
${data.replacesSaas ? `replaces_saas: "${data.replacesSaas.replace(/"/g, '\\"')}"` : '# replaces_saas: "ProductName"'}
|
|
510
|
+
# replaces_pricing: "$0-16/mo"
|
|
511
|
+
tags: []
|
|
512
|
+
estimated_tokens: 50000
|
|
513
|
+
estimated_cost_usd: 2.50
|
|
514
|
+
estimated_time_minutes: 30
|
|
515
|
+
`;
|
|
516
|
+
}
|
|
517
|
+
var SPECMARKET_YAML_TEMPLATE = (data) => buildSpecmarketYaml({
|
|
518
|
+
...data,
|
|
519
|
+
specFormat: "specmarket",
|
|
520
|
+
description: `A ${data.outputType} spec${data.replacesSaas ? ` that replaces ${data.replacesSaas}` : ""}.`
|
|
521
|
+
});
|
|
346
522
|
var SPEC_YAML_TEMPLATE = (data) => `# SpecMarket Spec Configuration
|
|
347
523
|
# See: https://specmarket.dev/docs/spec-yaml
|
|
348
524
|
|
|
@@ -355,7 +531,7 @@ ${data.replacesSaas ? `replaces_saas: "${data.replacesSaas}"` : '# replaces_saas
|
|
|
355
531
|
output_type: ${data.outputType}
|
|
356
532
|
primary_stack: ${data.primaryStack}
|
|
357
533
|
version: "1.0.0"
|
|
358
|
-
runner: claude
|
|
534
|
+
runner: claude
|
|
359
535
|
min_model: "claude-opus-4-5"
|
|
360
536
|
|
|
361
537
|
estimated_tokens: 50000
|
|
@@ -399,9 +575,9 @@ Read the requirements in SPEC.md and implement the application step by step.
|
|
|
399
575
|
## Process
|
|
400
576
|
|
|
401
577
|
1. Read SPEC.md completely before writing any code
|
|
402
|
-
2. Check
|
|
578
|
+
2. Check TASKS.md for outstanding items
|
|
403
579
|
3. Implement features, run tests, iterate
|
|
404
|
-
4. Update
|
|
580
|
+
4. Update TASKS.md as you complete items
|
|
405
581
|
5. Verify SUCCESS_CRITERIA.md criteria are met
|
|
406
582
|
|
|
407
583
|
## Rules
|
|
@@ -409,7 +585,7 @@ Read the requirements in SPEC.md and implement the application step by step.
|
|
|
409
585
|
- Follow stdlib/STACK.md for technology choices
|
|
410
586
|
- Write tests for all business logic
|
|
411
587
|
- Do not skip steps or take shortcuts
|
|
412
|
-
- Update
|
|
588
|
+
- Update TASKS.md after each significant change
|
|
413
589
|
`;
|
|
414
590
|
var SPEC_MD_TEMPLATE = (data) => `# ${data.displayName} \u2014 Specification
|
|
415
591
|
|
|
@@ -468,12 +644,12 @@ ${primaryStack}
|
|
|
468
644
|
- Vitest for unit tests
|
|
469
645
|
- Playwright for E2E (optional)
|
|
470
646
|
`;
|
|
471
|
-
var
|
|
647
|
+
var TASKS_MD_TEMPLATE = (displayName) => `# Tasks
|
|
472
648
|
|
|
473
649
|
> This file tracks outstanding work. Update it after each change.
|
|
474
|
-
>
|
|
650
|
+
> All items checked = implementation complete.
|
|
475
651
|
|
|
476
|
-
## ${displayName} \u2014 Initial Implementation
|
|
652
|
+
## Phase 1: ${displayName} \u2014 Initial Implementation
|
|
477
653
|
|
|
478
654
|
- [ ] Set up project structure and dependencies
|
|
479
655
|
- [ ] Implement core data model
|
|
@@ -482,9 +658,217 @@ var FIX_PLAN_TEMPLATE = (displayName) => `# Fix Plan
|
|
|
482
658
|
- [ ] Implement UI/interface
|
|
483
659
|
- [ ] Write integration tests
|
|
484
660
|
- [ ] Update README.md
|
|
661
|
+
|
|
662
|
+
## Discovered Issues
|
|
485
663
|
`;
|
|
664
|
+
async function promptMetadataOnly(defaultDisplayName) {
|
|
665
|
+
const { default: inquirer } = await import("inquirer");
|
|
666
|
+
const answers = await inquirer.prompt([
|
|
667
|
+
{
|
|
668
|
+
type: "input",
|
|
669
|
+
name: "displayName",
|
|
670
|
+
message: "Display name for the marketplace:",
|
|
671
|
+
default: defaultDisplayName ?? "My Spec"
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
type: "input",
|
|
675
|
+
name: "description",
|
|
676
|
+
message: "Short description (min 10 characters):",
|
|
677
|
+
default: "A spec ready to validate and publish on SpecMarket.",
|
|
678
|
+
validate: (v) => v.length >= 10 ? true : "Description must be at least 10 characters"
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
type: "input",
|
|
682
|
+
name: "replacesSaas",
|
|
683
|
+
message: "What SaaS product does this replace? (optional, Enter to skip):",
|
|
684
|
+
default: ""
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
type: "list",
|
|
688
|
+
name: "outputType",
|
|
689
|
+
message: "Output type:",
|
|
690
|
+
choices: [
|
|
691
|
+
{ name: "Web Application", value: "web-app" },
|
|
692
|
+
{ name: "CLI Tool", value: "cli-tool" },
|
|
693
|
+
{ name: "API Service", value: "api-service" },
|
|
694
|
+
{ name: "Library/Package", value: "library" },
|
|
695
|
+
{ name: "Mobile App", value: "mobile-app" }
|
|
696
|
+
]
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
type: "list",
|
|
700
|
+
name: "primaryStack",
|
|
701
|
+
message: "Primary stack:",
|
|
702
|
+
choices: [
|
|
703
|
+
{ name: "Next.js + TypeScript", value: "nextjs-typescript" },
|
|
704
|
+
{ name: "Astro + TypeScript", value: "astro-typescript" },
|
|
705
|
+
{ name: "Python + FastAPI", value: "python-fastapi" },
|
|
706
|
+
{ name: "Go", value: "go" },
|
|
707
|
+
{ name: "Rust", value: "rust" },
|
|
708
|
+
{ name: "Other", value: "other" }
|
|
709
|
+
]
|
|
710
|
+
}
|
|
711
|
+
]);
|
|
712
|
+
return {
|
|
713
|
+
displayName: answers.displayName,
|
|
714
|
+
description: answers.description,
|
|
715
|
+
replacesSaas: answers.replacesSaas || void 0,
|
|
716
|
+
outputType: answers.outputType,
|
|
717
|
+
primaryStack: answers.primaryStack
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
async function dirHasFiles(dir) {
|
|
721
|
+
try {
|
|
722
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
723
|
+
return entries.some((e) => e.isFile());
|
|
724
|
+
} catch {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
486
728
|
async function handleInit(opts) {
|
|
487
729
|
const { default: inquirer } = await import("inquirer");
|
|
730
|
+
if (opts.from !== void 0 && opts.from !== "") {
|
|
731
|
+
const targetDir2 = resolve(opts.from);
|
|
732
|
+
const dirExists = await directoryExists(targetDir2);
|
|
733
|
+
if (!dirExists) {
|
|
734
|
+
console.error(chalk4.red(`Directory not found: ${targetDir2}`));
|
|
735
|
+
console.error(chalk4.gray("--from requires an existing spec directory. Run specmarket init (no flags) to create a new spec from scratch."));
|
|
736
|
+
process.exit(EXIT_CODES.INVALID_SPEC);
|
|
737
|
+
}
|
|
738
|
+
const hasAnyFiles = await dirHasFiles(targetDir2);
|
|
739
|
+
if (!hasAnyFiles) {
|
|
740
|
+
console.error(chalk4.red(`Directory is empty: ${targetDir2}`));
|
|
741
|
+
console.error(chalk4.gray("--from requires a directory with spec files (Spec Kit, BMAD, Ralph, or custom markdown). Run specmarket init to create a new spec."));
|
|
742
|
+
process.exit(EXIT_CODES.INVALID_SPEC);
|
|
743
|
+
}
|
|
744
|
+
const sidecarPath = join3(targetDir2, SIDECAR_FILENAME);
|
|
745
|
+
if (await fileExists(sidecarPath)) {
|
|
746
|
+
console.log(chalk4.yellow(`${SIDECAR_FILENAME} already exists in this directory.`));
|
|
747
|
+
console.log(chalk4.gray("Run specmarket validate to check your spec, then specmarket publish to publish."));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const detection = await detectSpecFormat(targetDir2);
|
|
751
|
+
const formatLabel = detection.format === "specmarket" ? "SpecMarket (spec.yaml + PROMPT.md + \u2026)" : detection.format === "speckit" ? "Spec Kit" : detection.format === "bmad" ? "BMAD" : detection.format === "ralph" ? "Ralph" : "custom markdown";
|
|
752
|
+
console.log(chalk4.gray(`Detected ${formatLabel} spec. Adding SpecMarket metadata only; your files will not be modified.`));
|
|
753
|
+
console.log("");
|
|
754
|
+
const metadata = await promptMetadataOnly(basename(targetDir2));
|
|
755
|
+
const yaml = buildSpecmarketYaml({ ...metadata, specFormat: detection.format });
|
|
756
|
+
await writeFile2(sidecarPath, yaml);
|
|
757
|
+
console.log("");
|
|
758
|
+
console.log(chalk4.green(`Added ${SIDECAR_FILENAME} to ${targetDir2}`));
|
|
759
|
+
console.log("");
|
|
760
|
+
console.log(chalk4.bold("Next steps:"));
|
|
761
|
+
console.log(` 1. Run ${chalk4.cyan("specmarket validate")} to check your spec`);
|
|
762
|
+
console.log(` 2. Run ${chalk4.cyan("specmarket publish")} to publish to the marketplace`);
|
|
763
|
+
console.log(` 3. Run ${chalk4.cyan("specmarket run")} to execute the spec locally`);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (opts.path !== void 0 && opts.path !== "") {
|
|
767
|
+
const targetDir2 = resolve(opts.path);
|
|
768
|
+
await mkdir2(targetDir2, { recursive: true });
|
|
769
|
+
const sidecarPath = join3(targetDir2, SIDECAR_FILENAME);
|
|
770
|
+
if (await fileExists(sidecarPath)) {
|
|
771
|
+
console.log(chalk4.yellow(`${SIDECAR_FILENAME} already exists in this directory.`));
|
|
772
|
+
console.log(chalk4.gray("Run specmarket validate to check your spec, then specmarket publish to publish."));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const detection = await detectSpecFormat(targetDir2);
|
|
776
|
+
const hasAnyFiles = await dirHasFiles(targetDir2);
|
|
777
|
+
if (hasAnyFiles && detection.format !== "custom") {
|
|
778
|
+
const formatLabel = detection.format === "specmarket" ? "SpecMarket (spec.yaml + PROMPT.md + \u2026)" : detection.format === "speckit" ? "Spec Kit" : detection.format === "bmad" ? "BMAD" : detection.format === "ralph" ? "Ralph" : detection.format;
|
|
779
|
+
console.log(chalk4.gray(`Detected ${formatLabel} spec. Adding SpecMarket metadata only; your files will not be modified.`));
|
|
780
|
+
console.log("");
|
|
781
|
+
const metadata = await promptMetadataOnly(basename(targetDir2));
|
|
782
|
+
const yaml = buildSpecmarketYaml({
|
|
783
|
+
...metadata,
|
|
784
|
+
specFormat: detection.format
|
|
785
|
+
});
|
|
786
|
+
await writeFile2(sidecarPath, yaml);
|
|
787
|
+
console.log("");
|
|
788
|
+
console.log(chalk4.green(`Added ${SIDECAR_FILENAME} to ${targetDir2}`));
|
|
789
|
+
console.log("");
|
|
790
|
+
console.log(chalk4.bold("Next steps:"));
|
|
791
|
+
console.log(` 1. Run ${chalk4.cyan("specmarket validate")} to check your spec`);
|
|
792
|
+
console.log(` 2. Run ${chalk4.cyan("specmarket publish")} to publish to the marketplace`);
|
|
793
|
+
console.log(` 3. Run ${chalk4.cyan("specmarket run")} to execute the spec locally`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (hasAnyFiles && detection.format === "custom") {
|
|
797
|
+
console.log(chalk4.gray("Detected markdown spec. Adding SpecMarket metadata only; your files will not be modified."));
|
|
798
|
+
console.log("");
|
|
799
|
+
const metadata = await promptMetadataOnly(basename(targetDir2));
|
|
800
|
+
const yaml = buildSpecmarketYaml({
|
|
801
|
+
...metadata,
|
|
802
|
+
specFormat: "custom"
|
|
803
|
+
});
|
|
804
|
+
await writeFile2(sidecarPath, yaml);
|
|
805
|
+
console.log("");
|
|
806
|
+
console.log(chalk4.green(`Added ${SIDECAR_FILENAME} to ${targetDir2}`));
|
|
807
|
+
console.log("");
|
|
808
|
+
console.log(chalk4.bold("Next steps:"));
|
|
809
|
+
console.log(` 1. Run ${chalk4.cyan("specmarket validate")} to check your spec`);
|
|
810
|
+
console.log(` 2. Run ${chalk4.cyan("specmarket publish")} to publish to the marketplace`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
console.log(chalk4.gray("Directory is empty or has no recognized spec format."));
|
|
814
|
+
const { createNew } = await inquirer.prompt([
|
|
815
|
+
{
|
|
816
|
+
type: "confirm",
|
|
817
|
+
name: "createNew",
|
|
818
|
+
message: "Create a new SpecMarket spec from scratch here?",
|
|
819
|
+
default: true
|
|
820
|
+
}
|
|
821
|
+
]);
|
|
822
|
+
if (!createNew) {
|
|
823
|
+
console.log(chalk4.gray("Exiting. Add spec files (e.g. Spec Kit, BMAD) and run specmarket init -p . again."));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const spinner2 = ora2(`Creating spec at ${targetDir2}...`).start();
|
|
827
|
+
const fullAnswers = await inquirer.prompt([
|
|
828
|
+
{ type: "input", name: "name", message: "Spec name (lowercase, hyphens only):", default: opts.name ?? (basename(targetDir2) || "my-spec"), validate: (v) => /^[a-z0-9-]+$/.test(v) || "Must be lowercase alphanumeric with hyphens" },
|
|
829
|
+
{ type: "input", name: "displayName", message: "Display name:", default: (a) => a.name.split("-").map((w) => w[0]?.toUpperCase() + w.slice(1)).join(" ") },
|
|
830
|
+
{ type: "input", name: "replacesSaas", message: "What SaaS product does this replace? (optional):", default: "" },
|
|
831
|
+
{ type: "list", name: "outputType", message: "Output type:", choices: [
|
|
832
|
+
{ name: "Web Application", value: "web-app" },
|
|
833
|
+
{ name: "CLI Tool", value: "cli-tool" },
|
|
834
|
+
{ name: "API Service", value: "api-service" },
|
|
835
|
+
{ name: "Library/Package", value: "library" },
|
|
836
|
+
{ name: "Mobile App", value: "mobile-app" }
|
|
837
|
+
] },
|
|
838
|
+
{ type: "list", name: "primaryStack", message: "Primary stack:", choices: [
|
|
839
|
+
{ name: "Next.js + TypeScript", value: "nextjs-typescript" },
|
|
840
|
+
{ name: "Astro + TypeScript", value: "astro-typescript" },
|
|
841
|
+
{ name: "Python + FastAPI", value: "python-fastapi" },
|
|
842
|
+
{ name: "Go", value: "go" },
|
|
843
|
+
{ name: "Rust", value: "rust" },
|
|
844
|
+
{ name: "Other", value: "other" }
|
|
845
|
+
] }
|
|
846
|
+
]);
|
|
847
|
+
const data = {
|
|
848
|
+
name: fullAnswers.name,
|
|
849
|
+
displayName: fullAnswers.displayName,
|
|
850
|
+
replacesSaas: fullAnswers.replacesSaas || void 0,
|
|
851
|
+
outputType: fullAnswers.outputType,
|
|
852
|
+
primaryStack: fullAnswers.primaryStack
|
|
853
|
+
};
|
|
854
|
+
await mkdir2(join3(targetDir2, "stdlib"), { recursive: true });
|
|
855
|
+
await Promise.all([
|
|
856
|
+
writeFile2(sidecarPath, SPECMARKET_YAML_TEMPLATE(data)),
|
|
857
|
+
writeFile2(join3(targetDir2, "spec.yaml"), SPEC_YAML_TEMPLATE(data)),
|
|
858
|
+
writeFile2(join3(targetDir2, "PROMPT.md"), PROMPT_MD_TEMPLATE(data)),
|
|
859
|
+
writeFile2(join3(targetDir2, "SPEC.md"), SPEC_MD_TEMPLATE(data)),
|
|
860
|
+
writeFile2(join3(targetDir2, "SUCCESS_CRITERIA.md"), SUCCESS_CRITERIA_TEMPLATE),
|
|
861
|
+
writeFile2(join3(targetDir2, "stdlib", "STACK.md"), STACK_MD_TEMPLATE(fullAnswers.primaryStack)),
|
|
862
|
+
writeFile2(join3(targetDir2, "TASKS.md"), TASKS_MD_TEMPLATE(fullAnswers.displayName))
|
|
863
|
+
]);
|
|
864
|
+
spinner2.succeed(chalk4.green(`Spec created at ${targetDir2}`));
|
|
865
|
+
console.log("");
|
|
866
|
+
console.log(chalk4.bold("Next steps:"));
|
|
867
|
+
console.log(` 1. Edit ${chalk4.cyan("SPEC.md")} with your application requirements`);
|
|
868
|
+
console.log(` 2. Edit ${chalk4.cyan("SUCCESS_CRITERIA.md")} with pass/fail criteria`);
|
|
869
|
+
console.log(` 3. Run ${chalk4.cyan("specmarket validate")} then ${chalk4.cyan("specmarket run")}`);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
488
872
|
const answers = await inquirer.prompt([
|
|
489
873
|
{
|
|
490
874
|
type: "input",
|
|
@@ -497,7 +881,7 @@ async function handleInit(opts) {
|
|
|
497
881
|
type: "input",
|
|
498
882
|
name: "displayName",
|
|
499
883
|
message: "Display name:",
|
|
500
|
-
default: (
|
|
884
|
+
default: (ans) => ans.name.split("-").map((w) => w[0]?.toUpperCase() + w.slice(1)).join(" ")
|
|
501
885
|
},
|
|
502
886
|
{
|
|
503
887
|
type: "input",
|
|
@@ -531,11 +915,40 @@ async function handleInit(opts) {
|
|
|
531
915
|
]
|
|
532
916
|
}
|
|
533
917
|
]);
|
|
534
|
-
const targetDir = resolve(
|
|
918
|
+
const targetDir = resolve(answers.name);
|
|
535
919
|
const spinner = ora2(`Creating spec directory at ${targetDir}...`).start();
|
|
536
920
|
try {
|
|
537
921
|
await mkdir2(targetDir, { recursive: true });
|
|
538
|
-
|
|
922
|
+
const sidecarPath = join3(targetDir, SIDECAR_FILENAME);
|
|
923
|
+
if (await fileExists(sidecarPath)) {
|
|
924
|
+
spinner.stop();
|
|
925
|
+
console.log(chalk4.yellow(`${SIDECAR_FILENAME} already exists in ${targetDir}.`));
|
|
926
|
+
console.log(chalk4.gray("Run specmarket validate to check your spec, then specmarket publish to publish."));
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const hasFiles = await dirHasFiles(targetDir);
|
|
930
|
+
if (hasFiles) {
|
|
931
|
+
const detection = await detectSpecFormat(targetDir);
|
|
932
|
+
spinner.stop();
|
|
933
|
+
console.log(chalk4.gray(`Directory already has files (detected: ${detection.format}). Adding ${SIDECAR_FILENAME} only; no files overwritten.`));
|
|
934
|
+
const metadata = {
|
|
935
|
+
displayName: answers.displayName,
|
|
936
|
+
description: `A ${answers.outputType} spec${answers.replacesSaas ? ` that replaces ${answers.replacesSaas}` : ""}.`,
|
|
937
|
+
replacesSaas: answers.replacesSaas || void 0,
|
|
938
|
+
outputType: answers.outputType,
|
|
939
|
+
primaryStack: answers.primaryStack
|
|
940
|
+
};
|
|
941
|
+
const yaml = buildSpecmarketYaml({ ...metadata, specFormat: detection.format });
|
|
942
|
+
await writeFile2(sidecarPath, yaml);
|
|
943
|
+
console.log("");
|
|
944
|
+
console.log(chalk4.green(`Added ${SIDECAR_FILENAME} to ${targetDir}`));
|
|
945
|
+
console.log("");
|
|
946
|
+
console.log(chalk4.bold("Next steps:"));
|
|
947
|
+
console.log(` 1. Run ${chalk4.cyan("specmarket validate")} to check your spec`);
|
|
948
|
+
console.log(` 2. Run ${chalk4.cyan("specmarket publish")} to publish to the marketplace`);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
await mkdir2(join3(targetDir, "stdlib"), { recursive: true });
|
|
539
952
|
const data = {
|
|
540
953
|
name: answers.name,
|
|
541
954
|
displayName: answers.displayName,
|
|
@@ -544,12 +957,13 @@ async function handleInit(opts) {
|
|
|
544
957
|
primaryStack: answers.primaryStack
|
|
545
958
|
};
|
|
546
959
|
await Promise.all([
|
|
547
|
-
writeFile2(
|
|
548
|
-
writeFile2(
|
|
549
|
-
writeFile2(
|
|
550
|
-
writeFile2(
|
|
551
|
-
writeFile2(
|
|
552
|
-
writeFile2(
|
|
960
|
+
writeFile2(sidecarPath, SPECMARKET_YAML_TEMPLATE(data)),
|
|
961
|
+
writeFile2(join3(targetDir, "spec.yaml"), SPEC_YAML_TEMPLATE(data)),
|
|
962
|
+
writeFile2(join3(targetDir, "PROMPT.md"), PROMPT_MD_TEMPLATE(data)),
|
|
963
|
+
writeFile2(join3(targetDir, "SPEC.md"), SPEC_MD_TEMPLATE(data)),
|
|
964
|
+
writeFile2(join3(targetDir, "SUCCESS_CRITERIA.md"), SUCCESS_CRITERIA_TEMPLATE),
|
|
965
|
+
writeFile2(join3(targetDir, "stdlib", "STACK.md"), STACK_MD_TEMPLATE(answers.primaryStack)),
|
|
966
|
+
writeFile2(join3(targetDir, "TASKS.md"), TASKS_MD_TEMPLATE(answers.displayName))
|
|
553
967
|
]);
|
|
554
968
|
spinner.succeed(chalk4.green(`Spec created at ${targetDir}`));
|
|
555
969
|
console.log("");
|
|
@@ -557,15 +971,17 @@ async function handleInit(opts) {
|
|
|
557
971
|
console.log(` 1. ${chalk4.cyan(`cd ${answers.name}`)}`);
|
|
558
972
|
console.log(` 2. Edit ${chalk4.cyan("SPEC.md")} with your application requirements`);
|
|
559
973
|
console.log(` 3. Edit ${chalk4.cyan("SUCCESS_CRITERIA.md")} with specific pass/fail criteria`);
|
|
560
|
-
console.log(` 4. Run ${chalk4.cyan(
|
|
561
|
-
console.log(` 5. Run ${chalk4.cyan(
|
|
974
|
+
console.log(` 4. Run ${chalk4.cyan("specmarket validate")} to check your spec`);
|
|
975
|
+
console.log(` 5. Run ${chalk4.cyan("specmarket run")} to execute the spec`);
|
|
562
976
|
} catch (err) {
|
|
563
977
|
spinner.fail(chalk4.red(`Failed to create spec: ${err.message}`));
|
|
564
978
|
throw err;
|
|
565
979
|
}
|
|
566
980
|
}
|
|
567
981
|
function createInitCommand() {
|
|
568
|
-
return new Command4("init").description(
|
|
982
|
+
return new Command4("init").description(
|
|
983
|
+
"Create a new SpecMarket spec or add specmarket.yaml to an existing spec (Spec Kit, BMAD, Ralph). Use -p . to init in current directory without overwriting existing files."
|
|
984
|
+
).option("-n, --name <name>", "Spec name (skip prompt)").option("-p, --path <path>", "Target directory (e.g. . or ./my-spec). When set, detects existing format and adds only specmarket.yaml if present; no files overwritten.").option("--from <path>", "Import an existing spec directory (Spec Kit, BMAD, Ralph, or custom markdown). Detects format and adds specmarket.yaml metadata sidecar without modifying original files. Errors if the directory is missing or empty.").action(async (opts) => {
|
|
569
985
|
try {
|
|
570
986
|
await handleInit(opts);
|
|
571
987
|
} catch (err) {
|
|
@@ -578,123 +994,29 @@ function createInitCommand() {
|
|
|
578
994
|
// src/commands/validate.ts
|
|
579
995
|
import { Command as Command5 } from "commander";
|
|
580
996
|
import chalk5 from "chalk";
|
|
581
|
-
import { readFile as
|
|
582
|
-
import { join as
|
|
583
|
-
import { parse as
|
|
584
|
-
async function
|
|
585
|
-
const
|
|
586
|
-
const errors = [];
|
|
587
|
-
const warnings = [];
|
|
588
|
-
for (const file of REQUIRED_SPEC_FILES) {
|
|
589
|
-
const filePath = join3(dir, file);
|
|
590
|
-
try {
|
|
591
|
-
await access(filePath);
|
|
592
|
-
const content = await readFile2(filePath, "utf-8");
|
|
593
|
-
if (content.trim().length === 0) {
|
|
594
|
-
errors.push(`${file} exists but is empty`);
|
|
595
|
-
}
|
|
596
|
-
} catch {
|
|
597
|
-
errors.push(`Required file missing: ${file}`);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
const stdlibDir = join3(dir, "stdlib");
|
|
601
|
-
for (const file of REQUIRED_STDLIB_FILES) {
|
|
602
|
-
const filePath = join3(stdlibDir, file);
|
|
603
|
-
try {
|
|
604
|
-
await access(filePath);
|
|
605
|
-
const content = await readFile2(filePath, "utf-8");
|
|
606
|
-
if (content.trim().length === 0) {
|
|
607
|
-
errors.push(`stdlib/${file} exists but is empty`);
|
|
608
|
-
}
|
|
609
|
-
} catch {
|
|
610
|
-
errors.push(`Required file missing: stdlib/${file}`);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
let specYaml = null;
|
|
614
|
-
const specYamlPath = join3(dir, "spec.yaml");
|
|
997
|
+
import { readFile as readFile3, readdir as readdir3, access as access2 } from "fs/promises";
|
|
998
|
+
import { join as join4, resolve as resolve2, relative, normalize } from "path";
|
|
999
|
+
import { parse as parseYaml2 } from "yaml";
|
|
1000
|
+
async function collectFiles(currentDir, baseDir, extensions) {
|
|
1001
|
+
const results = [];
|
|
615
1002
|
try {
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
);
|
|
628
|
-
}
|
|
629
|
-
} else {
|
|
630
|
-
const parsed = parseResult.data;
|
|
631
|
-
try {
|
|
632
|
-
const criteriaContent = await readFile2(join3(dir, "SUCCESS_CRITERIA.md"), "utf-8");
|
|
633
|
-
const hasCriterion = /^- \[[ x]\]/m.test(criteriaContent);
|
|
634
|
-
if (!hasCriterion) {
|
|
635
|
-
errors.push(
|
|
636
|
-
"SUCCESS_CRITERIA.md: Must contain at least one criterion in format: - [ ] criterion text"
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
} catch {
|
|
640
|
-
}
|
|
641
|
-
const cycles = await detectCircularReferences(dir);
|
|
642
|
-
for (const cycle of cycles) {
|
|
643
|
-
errors.push(`Circular reference detected: ${cycle}`);
|
|
644
|
-
}
|
|
645
|
-
if (parsed.infrastructure) {
|
|
646
|
-
const infra = parsed.infrastructure;
|
|
647
|
-
if (["web-app", "api-service", "mobile-app"].includes(parsed.output_type) && infra.services.length === 0) {
|
|
648
|
-
warnings.push(
|
|
649
|
-
`${parsed.output_type} should typically define infrastructure services (database, auth, etc.)`
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
if (!infra.setup_time_minutes) {
|
|
653
|
-
warnings.push("infrastructure.setup_time_minutes is not set");
|
|
654
|
-
}
|
|
655
|
-
for (const service of infra.services) {
|
|
656
|
-
if (service.default_provider) {
|
|
657
|
-
const providerNames = service.providers.map((p) => p.name);
|
|
658
|
-
if (!providerNames.includes(service.default_provider)) {
|
|
659
|
-
errors.push(
|
|
660
|
-
`infrastructure.services[${service.name}].default_provider "${service.default_provider}" does not match any defined provider (${providerNames.join(", ")})`
|
|
661
|
-
);
|
|
662
|
-
}
|
|
1003
|
+
const entries = await readdir3(currentDir, { withFileTypes: true });
|
|
1004
|
+
for (const entry of entries) {
|
|
1005
|
+
const fullPath = join4(currentDir, entry.name);
|
|
1006
|
+
if (entry.isDirectory()) {
|
|
1007
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
1008
|
+
const subFiles = await collectFiles(fullPath, baseDir, extensions);
|
|
1009
|
+
results.push(...subFiles);
|
|
1010
|
+
} else if (entry.isFile()) {
|
|
1011
|
+
const ext = entry.name.includes(".") ? "." + entry.name.split(".").pop() : "";
|
|
1012
|
+
if (extensions.has(ext)) {
|
|
1013
|
+
results.push(relative(baseDir, fullPath));
|
|
663
1014
|
}
|
|
664
1015
|
}
|
|
665
|
-
} else {
|
|
666
|
-
if (["web-app", "api-service", "mobile-app"].includes(parsed.output_type)) {
|
|
667
|
-
warnings.push(
|
|
668
|
-
"No infrastructure block defined. Consider adding infrastructure.services for database, auth, etc."
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
if (parsed.estimated_tokens < 1e3) {
|
|
673
|
-
warnings.push(
|
|
674
|
-
`estimated_tokens (${parsed.estimated_tokens}) seems very low. Most specs use 10,000+.`
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
if (parsed.estimated_tokens > 1e7) {
|
|
678
|
-
warnings.push(
|
|
679
|
-
`estimated_tokens (${parsed.estimated_tokens}) seems very high.`
|
|
680
|
-
);
|
|
681
|
-
}
|
|
682
|
-
if (parsed.estimated_cost_usd < 0.01) {
|
|
683
|
-
warnings.push(
|
|
684
|
-
`estimated_cost_usd ($${parsed.estimated_cost_usd}) seems very low.`
|
|
685
|
-
);
|
|
686
|
-
}
|
|
687
|
-
if (parsed.estimated_time_minutes < 1) {
|
|
688
|
-
warnings.push(
|
|
689
|
-
`estimated_time_minutes (${parsed.estimated_time_minutes}) seems unrealistically low.`
|
|
690
|
-
);
|
|
691
1016
|
}
|
|
1017
|
+
} catch {
|
|
692
1018
|
}
|
|
693
|
-
return
|
|
694
|
-
valid: errors.length === 0,
|
|
695
|
-
errors,
|
|
696
|
-
warnings
|
|
697
|
-
};
|
|
1019
|
+
return results;
|
|
698
1020
|
}
|
|
699
1021
|
async function detectCircularReferences(dir) {
|
|
700
1022
|
const textExtensions = /* @__PURE__ */ new Set([".md", ".yaml", ".yml"]);
|
|
@@ -704,7 +1026,7 @@ async function detectCircularReferences(dir) {
|
|
|
704
1026
|
for (const file of files) {
|
|
705
1027
|
const refs = /* @__PURE__ */ new Set();
|
|
706
1028
|
try {
|
|
707
|
-
const content = await
|
|
1029
|
+
const content = await readFile3(join4(dir, file), "utf-8");
|
|
708
1030
|
let match;
|
|
709
1031
|
while ((match = linkPattern.exec(content)) !== null) {
|
|
710
1032
|
const target = match[1];
|
|
@@ -713,7 +1035,7 @@ async function detectCircularReferences(dir) {
|
|
|
713
1035
|
}
|
|
714
1036
|
const targetPath = target.split("#")[0];
|
|
715
1037
|
if (!targetPath) continue;
|
|
716
|
-
const fileDir =
|
|
1038
|
+
const fileDir = join4(dir, file, "..");
|
|
717
1039
|
const resolvedTarget = normalize(relative(dir, resolve2(fileDir, targetPath)));
|
|
718
1040
|
if (!resolvedTarget.startsWith("..") && files.includes(resolvedTarget)) {
|
|
719
1041
|
refs.add(resolvedTarget);
|
|
@@ -760,31 +1082,249 @@ async function detectCircularReferences(dir) {
|
|
|
760
1082
|
}
|
|
761
1083
|
return cycles;
|
|
762
1084
|
}
|
|
763
|
-
async function
|
|
764
|
-
const
|
|
1085
|
+
async function validateSpecmarketContent(dir, errors, warnings) {
|
|
1086
|
+
for (const file of REQUIRED_SPEC_FILES) {
|
|
1087
|
+
const filePath = join4(dir, file);
|
|
1088
|
+
try {
|
|
1089
|
+
await access2(filePath);
|
|
1090
|
+
const content = await readFile3(filePath, "utf-8");
|
|
1091
|
+
if (content.trim().length === 0) {
|
|
1092
|
+
errors.push(`${file} exists but is empty`);
|
|
1093
|
+
}
|
|
1094
|
+
} catch {
|
|
1095
|
+
errors.push(`Required file missing: ${file}`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
const stdlibDir = join4(dir, "stdlib");
|
|
1099
|
+
for (const file of REQUIRED_STDLIB_FILES) {
|
|
1100
|
+
const filePath = join4(stdlibDir, file);
|
|
1101
|
+
try {
|
|
1102
|
+
await access2(filePath);
|
|
1103
|
+
const content = await readFile3(filePath, "utf-8");
|
|
1104
|
+
if (content.trim().length === 0) {
|
|
1105
|
+
errors.push(`stdlib/${file} exists but is empty`);
|
|
1106
|
+
}
|
|
1107
|
+
} catch {
|
|
1108
|
+
errors.push(`Required file missing: stdlib/${file}`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
let specYaml = null;
|
|
1112
|
+
const specYamlPath = join4(dir, "spec.yaml");
|
|
765
1113
|
try {
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1114
|
+
const raw = await readFile3(specYamlPath, "utf-8");
|
|
1115
|
+
specYaml = parseYaml2(raw);
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
errors.push(`spec.yaml: Failed to parse YAML: ${err.message}`);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
const parseResult = specYamlSchema.safeParse(specYaml);
|
|
1121
|
+
if (!parseResult.success) {
|
|
1122
|
+
for (const issue of parseResult.error.issues) {
|
|
1123
|
+
errors.push(`spec.yaml: ${issue.path.join(".")} \u2014 ${issue.message}`);
|
|
1124
|
+
}
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const parsed = parseResult.data;
|
|
1128
|
+
try {
|
|
1129
|
+
const criteriaContent = await readFile3(join4(dir, "SUCCESS_CRITERIA.md"), "utf-8");
|
|
1130
|
+
const hasCriterion = /^- \[[ x]\]/m.test(criteriaContent);
|
|
1131
|
+
if (!hasCriterion) {
|
|
1132
|
+
errors.push(
|
|
1133
|
+
"SUCCESS_CRITERIA.md: Must contain at least one criterion in format: - [ ] criterion text"
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
} catch {
|
|
1137
|
+
}
|
|
1138
|
+
const cycles = await detectCircularReferences(dir);
|
|
1139
|
+
for (const cycle of cycles) {
|
|
1140
|
+
errors.push(`Circular reference detected: ${cycle}`);
|
|
1141
|
+
}
|
|
1142
|
+
if (parsed.infrastructure) {
|
|
1143
|
+
const infra = parsed.infrastructure;
|
|
1144
|
+
if (["web-app", "api-service", "mobile-app"].includes(parsed.output_type) && infra.services.length === 0) {
|
|
1145
|
+
warnings.push(
|
|
1146
|
+
`${parsed.output_type} should typically define infrastructure services (database, auth, etc.)`
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
if (!infra.setup_time_minutes) {
|
|
1150
|
+
warnings.push("infrastructure.setup_time_minutes is not set");
|
|
1151
|
+
}
|
|
1152
|
+
for (const service of infra.services) {
|
|
1153
|
+
if (service.default_provider) {
|
|
1154
|
+
const providerNames = service.providers.map((p) => p.name);
|
|
1155
|
+
if (!providerNames.includes(service.default_provider)) {
|
|
1156
|
+
errors.push(
|
|
1157
|
+
`infrastructure.services[${service.name}].default_provider "${service.default_provider}" does not match any defined provider (${providerNames.join(", ")})`
|
|
1158
|
+
);
|
|
777
1159
|
}
|
|
778
1160
|
}
|
|
779
1161
|
}
|
|
1162
|
+
} else {
|
|
1163
|
+
if (["web-app", "api-service", "mobile-app"].includes(parsed.output_type)) {
|
|
1164
|
+
warnings.push(
|
|
1165
|
+
"No infrastructure block defined. Consider adding infrastructure.services for database, auth, etc."
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (parsed.estimated_tokens < 1e3) {
|
|
1170
|
+
warnings.push(
|
|
1171
|
+
`estimated_tokens (${parsed.estimated_tokens}) seems very low. Most specs use 10,000+.`
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
if (parsed.estimated_tokens > 1e7) {
|
|
1175
|
+
warnings.push(`estimated_tokens (${parsed.estimated_tokens}) seems very high.`);
|
|
1176
|
+
}
|
|
1177
|
+
if (parsed.estimated_cost_usd < 0.01) {
|
|
1178
|
+
warnings.push(
|
|
1179
|
+
`estimated_cost_usd ($${parsed.estimated_cost_usd}) seems very low.`
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
if (parsed.estimated_time_minutes < 1) {
|
|
1183
|
+
warnings.push(
|
|
1184
|
+
`estimated_time_minutes (${parsed.estimated_time_minutes}) seems unrealistically low.`
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
async function validateSpec(specPath) {
|
|
1189
|
+
const dir = resolve2(specPath);
|
|
1190
|
+
const errors = [];
|
|
1191
|
+
const warnings = [];
|
|
1192
|
+
let format;
|
|
1193
|
+
let formatDetectedBy = "sidecar";
|
|
1194
|
+
try {
|
|
1195
|
+
const entries = await readdir3(dir, { withFileTypes: true });
|
|
1196
|
+
const hasAnyFile = entries.some((e) => e.isFile());
|
|
1197
|
+
if (!hasAnyFile) {
|
|
1198
|
+
errors.push("Directory is empty or has no readable files");
|
|
1199
|
+
}
|
|
780
1200
|
} catch {
|
|
1201
|
+
errors.push("Directory is empty or unreadable");
|
|
781
1202
|
}
|
|
782
|
-
|
|
1203
|
+
const sidecarPath = join4(dir, SIDECAR_FILENAME);
|
|
1204
|
+
const sidecarExists = await fileExists(sidecarPath);
|
|
1205
|
+
if (!sidecarExists) {
|
|
1206
|
+
errors.push(`${SIDECAR_FILENAME} is required for all specs (single source of truth for format and metadata)`);
|
|
1207
|
+
} else {
|
|
1208
|
+
try {
|
|
1209
|
+
const raw = await readFile3(sidecarPath, "utf-8");
|
|
1210
|
+
const parsed = parseYaml2(raw);
|
|
1211
|
+
const sidecarResult = specmarketSidecarSchema.safeParse(parsed);
|
|
1212
|
+
if (!sidecarResult.success) {
|
|
1213
|
+
for (const issue of sidecarResult.error.issues) {
|
|
1214
|
+
errors.push(
|
|
1215
|
+
`${SIDECAR_FILENAME}: ${issue.path.join(".")} \u2014 ${issue.message}`
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
} else {
|
|
1219
|
+
const sidecar = sidecarResult.data;
|
|
1220
|
+
format = sidecar.spec_format;
|
|
1221
|
+
if (sidecar.estimated_tokens !== void 0) {
|
|
1222
|
+
if (sidecar.estimated_tokens < 1e3) {
|
|
1223
|
+
warnings.push(
|
|
1224
|
+
`estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
if (sidecar.estimated_tokens > 1e7) {
|
|
1228
|
+
warnings.push(`estimated_tokens (${sidecar.estimated_tokens}) seems very high.`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (sidecar.estimated_cost_usd !== void 0 && sidecar.estimated_cost_usd < 0.01) {
|
|
1232
|
+
warnings.push(
|
|
1233
|
+
`estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
if (sidecar.estimated_time_minutes !== void 0 && sidecar.estimated_time_minutes < 1) {
|
|
1237
|
+
warnings.push(
|
|
1238
|
+
`estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
switch (format) {
|
|
1242
|
+
case "specmarket":
|
|
1243
|
+
await validateSpecmarketContent(dir, errors, warnings);
|
|
1244
|
+
break;
|
|
1245
|
+
case "speckit": {
|
|
1246
|
+
const hasSpecMd = await fileExists(join4(dir, "spec.md"));
|
|
1247
|
+
const hasTasksMd = await fileExists(join4(dir, "tasks.md"));
|
|
1248
|
+
const hasPlanMd = await fileExists(join4(dir, "plan.md"));
|
|
1249
|
+
const hasSpecifyDir = await directoryExists(join4(dir, ".specify"));
|
|
1250
|
+
if (!hasSpecMd) errors.push("speckit format requires spec.md");
|
|
1251
|
+
if (!hasTasksMd && !hasPlanMd) errors.push("speckit format requires tasks.md or plan.md");
|
|
1252
|
+
if (!hasSpecifyDir) warnings.push("speckit format: .specify/ directory is recommended");
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
case "bmad": {
|
|
1256
|
+
const hasPrdMd = await fileExists(join4(dir, "prd.md"));
|
|
1257
|
+
const hasStory = await hasStoryFiles(dir);
|
|
1258
|
+
if (!hasPrdMd && !hasStory) errors.push("bmad format requires prd.md or story-*.md files");
|
|
1259
|
+
const hasArch = await fileExists(join4(dir, "architecture.md"));
|
|
1260
|
+
if (!hasArch) warnings.push("bmad format: architecture.md is recommended");
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
case "ralph": {
|
|
1264
|
+
const prdPath = join4(dir, "prd.json");
|
|
1265
|
+
if (!await fileExists(prdPath)) {
|
|
1266
|
+
errors.push("ralph format requires prd.json");
|
|
1267
|
+
break;
|
|
1268
|
+
}
|
|
1269
|
+
try {
|
|
1270
|
+
const raw2 = await readFile3(prdPath, "utf-8");
|
|
1271
|
+
const data = JSON.parse(raw2);
|
|
1272
|
+
if (!data || typeof data !== "object" || !("userStories" in data) || !Array.isArray(data.userStories)) {
|
|
1273
|
+
errors.push("ralph format: prd.json must have userStories array");
|
|
1274
|
+
}
|
|
1275
|
+
} catch {
|
|
1276
|
+
errors.push("ralph format: prd.json must be valid JSON with userStories array");
|
|
1277
|
+
}
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1280
|
+
case "custom":
|
|
1281
|
+
default: {
|
|
1282
|
+
const hasMd = await hasMarkdownFiles(dir);
|
|
1283
|
+
if (!hasMd) {
|
|
1284
|
+
errors.push("custom format requires at least one .md file");
|
|
1285
|
+
break;
|
|
1286
|
+
}
|
|
1287
|
+
const textExtensions = /* @__PURE__ */ new Set([".md"]);
|
|
1288
|
+
const mdFiles = await collectFiles(dir, dir, textExtensions);
|
|
1289
|
+
let hasSubstantialMd = false;
|
|
1290
|
+
for (const f of mdFiles) {
|
|
1291
|
+
try {
|
|
1292
|
+
const content = await readFile3(join4(dir, f), "utf-8");
|
|
1293
|
+
if (content.length > 100) {
|
|
1294
|
+
hasSubstantialMd = true;
|
|
1295
|
+
break;
|
|
1296
|
+
}
|
|
1297
|
+
} catch {
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (!hasSubstantialMd) {
|
|
1301
|
+
errors.push("custom format requires at least one .md file larger than 100 bytes");
|
|
1302
|
+
}
|
|
1303
|
+
break;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
errors.push(
|
|
1309
|
+
`${SIDECAR_FILENAME}: Failed to read or parse: ${err.message}`
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
return {
|
|
1314
|
+
valid: errors.length === 0,
|
|
1315
|
+
errors,
|
|
1316
|
+
warnings,
|
|
1317
|
+
format,
|
|
1318
|
+
formatDetectedBy
|
|
1319
|
+
};
|
|
783
1320
|
}
|
|
784
1321
|
function createValidateCommand() {
|
|
785
|
-
return new Command5("validate").description("Validate a spec directory for completeness and schema compliance").argument("
|
|
1322
|
+
return new Command5("validate").description("Validate a spec directory for completeness and schema compliance").argument("[path]", "Path to the spec directory (defaults to current directory)", ".").action(async (specPath) => {
|
|
786
1323
|
try {
|
|
787
1324
|
const result = await validateSpec(specPath);
|
|
1325
|
+
if (result.format !== void 0) {
|
|
1326
|
+
console.log(chalk5.gray(`Format: ${result.format}`));
|
|
1327
|
+
}
|
|
788
1328
|
if (result.warnings.length > 0) {
|
|
789
1329
|
console.log(chalk5.yellow("\nWarnings:"));
|
|
790
1330
|
for (const warning of result.warnings) {
|
|
@@ -819,9 +1359,9 @@ Validation failed with ${result.errors.length} error(s).`)
|
|
|
819
1359
|
import { Command as Command6 } from "commander";
|
|
820
1360
|
import chalk6 from "chalk";
|
|
821
1361
|
import ora3 from "ora";
|
|
822
|
-
import { readFile as
|
|
823
|
-
import { join as
|
|
824
|
-
import { parse as
|
|
1362
|
+
import { readFile as readFile6, mkdir as mkdir4, writeFile as writeFileFn } from "fs/promises";
|
|
1363
|
+
import { join as join7, resolve as resolve4, isAbsolute } from "path";
|
|
1364
|
+
import { parse as parseYaml4 } from "yaml";
|
|
825
1365
|
|
|
826
1366
|
// src/lib/telemetry.ts
|
|
827
1367
|
import createDebug5 from "debug";
|
|
@@ -852,6 +1392,11 @@ async function submitTelemetry(report, opts = {}) {
|
|
|
852
1392
|
specVersion: report.specVersion,
|
|
853
1393
|
model: report.model,
|
|
854
1394
|
runner: report.runner,
|
|
1395
|
+
harness: report.harness,
|
|
1396
|
+
specFormat: report.specFormat,
|
|
1397
|
+
environmentType: report.environmentType,
|
|
1398
|
+
steeringActionCount: report.steeringActionCount,
|
|
1399
|
+
isPureRun: report.isPureRun,
|
|
855
1400
|
loopCount: report.loopCount,
|
|
856
1401
|
totalTokens: report.totalTokens,
|
|
857
1402
|
totalCostUsd: report.totalCostUsd,
|
|
@@ -884,30 +1429,474 @@ async function promptTelemetryOptIn() {
|
|
|
884
1429
|
default: false
|
|
885
1430
|
}
|
|
886
1431
|
]);
|
|
887
|
-
const { saveConfig: saveConfig2 } = await import("./config-
|
|
1432
|
+
const { saveConfig: saveConfig2 } = await import("./config-5JMI3YAR.js");
|
|
888
1433
|
await saveConfig2({ ...config, telemetry: optIn, telemetryPrompted: true });
|
|
889
1434
|
return optIn;
|
|
890
1435
|
}
|
|
891
1436
|
|
|
892
1437
|
// src/lib/ralph-loop.ts
|
|
893
1438
|
import { spawn } from "child_process";
|
|
894
|
-
import { mkdir as mkdir3, writeFile as writeFile3, readFile as
|
|
895
|
-
import { join as
|
|
1439
|
+
import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile5, access as access3 } from "fs/promises";
|
|
1440
|
+
import { join as join6 } from "path";
|
|
896
1441
|
import { homedir as homedir2 } from "os";
|
|
897
1442
|
import { randomUUID } from "crypto";
|
|
898
1443
|
import { exec } from "child_process";
|
|
899
1444
|
import { promisify } from "util";
|
|
900
1445
|
import createDebug6 from "debug";
|
|
1446
|
+
|
|
1447
|
+
// src/lib/meta-instructions.ts
|
|
1448
|
+
import { readFile as readFile4, readdir as readdir4 } from "fs/promises";
|
|
1449
|
+
import { join as join5 } from "path";
|
|
1450
|
+
import { parse as parseYaml3 } from "yaml";
|
|
1451
|
+
var META_INSTRUCTION_FILENAME = ".specmarket-runner.md";
|
|
1452
|
+
async function readSidecarData(dir) {
|
|
1453
|
+
const sidecarPath = join5(dir, SIDECAR_FILENAME);
|
|
1454
|
+
if (!await fileExists(sidecarPath)) return {};
|
|
1455
|
+
try {
|
|
1456
|
+
const raw = await readFile4(sidecarPath, "utf-8");
|
|
1457
|
+
const parsed = parseYaml3(raw);
|
|
1458
|
+
if (parsed && typeof parsed === "object") {
|
|
1459
|
+
const d = parsed;
|
|
1460
|
+
return {
|
|
1461
|
+
display_name: typeof d["display_name"] === "string" ? d["display_name"] : void 0,
|
|
1462
|
+
description: typeof d["description"] === "string" ? d["description"] : void 0,
|
|
1463
|
+
spec_format: typeof d["spec_format"] === "string" ? d["spec_format"] : void 0
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
} catch {
|
|
1467
|
+
}
|
|
1468
|
+
return {};
|
|
1469
|
+
}
|
|
1470
|
+
async function listStoryFiles(dir) {
|
|
1471
|
+
try {
|
|
1472
|
+
const entries = await readdir4(dir, { withFileTypes: true });
|
|
1473
|
+
return entries.filter((e) => e.isFile() && e.name.startsWith("story-") && e.name.endsWith(".md")).map((e) => e.name).sort((a, b) => {
|
|
1474
|
+
const numA = parseInt(a.replace(/^story-(\d+).*/, "$1"), 10);
|
|
1475
|
+
const numB = parseInt(b.replace(/^story-(\d+).*/, "$1"), 10);
|
|
1476
|
+
if (!isNaN(numA) && !isNaN(numB)) return numA - numB;
|
|
1477
|
+
if (!isNaN(numA)) return -1;
|
|
1478
|
+
if (!isNaN(numB)) return 1;
|
|
1479
|
+
return a.localeCompare(b);
|
|
1480
|
+
});
|
|
1481
|
+
} catch {
|
|
1482
|
+
return [];
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
async function presentFiles(dir, candidates) {
|
|
1486
|
+
const found = [];
|
|
1487
|
+
for (const f of candidates) {
|
|
1488
|
+
if (await fileExists(join5(dir, f))) found.push(f);
|
|
1489
|
+
}
|
|
1490
|
+
return found;
|
|
1491
|
+
}
|
|
1492
|
+
function preamble(sidecar) {
|
|
1493
|
+
const lines = [
|
|
1494
|
+
"# SpecMarket Runner Instructions",
|
|
1495
|
+
"",
|
|
1496
|
+
"You are an AI coding agent executing a software specification.",
|
|
1497
|
+
"Read this file carefully before starting work. Follow the instructions exactly."
|
|
1498
|
+
];
|
|
1499
|
+
if (sidecar.display_name) {
|
|
1500
|
+
lines.push("", `**Spec:** ${sidecar.display_name}`);
|
|
1501
|
+
}
|
|
1502
|
+
if (sidecar.description) {
|
|
1503
|
+
lines.push(`**Description:** ${sidecar.description}`);
|
|
1504
|
+
}
|
|
1505
|
+
return lines.join("\n");
|
|
1506
|
+
}
|
|
1507
|
+
function testingSection() {
|
|
1508
|
+
return [
|
|
1509
|
+
"## Running Tests",
|
|
1510
|
+
"",
|
|
1511
|
+
"After completing each major task, run the test suite:",
|
|
1512
|
+
"",
|
|
1513
|
+
"- `package.json` present \u2192 `npm test -- --run` (or `npx vitest run` if vitest is configured)",
|
|
1514
|
+
"- `pytest.ini` or `pyproject.toml` present \u2192 `python -m pytest`",
|
|
1515
|
+
"- `Makefile` with `test` target \u2192 `make test`",
|
|
1516
|
+
"- No test runner found \u2192 verify functionality manually by running the application",
|
|
1517
|
+
"",
|
|
1518
|
+
"Fix all test failures before proceeding to the next task."
|
|
1519
|
+
].join("\n");
|
|
1520
|
+
}
|
|
1521
|
+
function completionReminder() {
|
|
1522
|
+
return [
|
|
1523
|
+
"## Important Reminders",
|
|
1524
|
+
"",
|
|
1525
|
+
"- Only mark a task complete when you have **fully implemented and tested** it.",
|
|
1526
|
+
"- Do not skip tasks. Do not leave stubs or placeholders.",
|
|
1527
|
+
"- If you discover a blocking issue, note it in the relevant task file and continue with other tasks if possible.",
|
|
1528
|
+
"- Run the full test suite one final time before declaring the spec complete."
|
|
1529
|
+
].join("\n");
|
|
1530
|
+
}
|
|
1531
|
+
async function generateSpecmarketInstructions(dir, sidecar) {
|
|
1532
|
+
const coreFiles = await presentFiles(dir, [
|
|
1533
|
+
"PROMPT.md",
|
|
1534
|
+
"SPEC.md",
|
|
1535
|
+
"SUCCESS_CRITERIA.md",
|
|
1536
|
+
"TASKS.md",
|
|
1537
|
+
"spec.yaml",
|
|
1538
|
+
"stdlib/STACK.md",
|
|
1539
|
+
"stdlib/PATTERNS.md",
|
|
1540
|
+
"stdlib/SECURITY.md"
|
|
1541
|
+
]);
|
|
1542
|
+
const fileList = coreFiles.length > 0 ? coreFiles.map((f) => `- \`${f}\``).join("\n") : "- *(no standard spec files found \u2014 inspect directory contents)*";
|
|
1543
|
+
const readingOrderParts = [];
|
|
1544
|
+
if (coreFiles.includes("PROMPT.md")) {
|
|
1545
|
+
readingOrderParts.push("`PROMPT.md` for the high-level goal");
|
|
1546
|
+
}
|
|
1547
|
+
if (coreFiles.includes("SPEC.md")) {
|
|
1548
|
+
readingOrderParts.push("`SPEC.md` for full requirements");
|
|
1549
|
+
}
|
|
1550
|
+
if (coreFiles.includes("SUCCESS_CRITERIA.md")) {
|
|
1551
|
+
readingOrderParts.push('`SUCCESS_CRITERIA.md` to understand exactly what "done" means');
|
|
1552
|
+
}
|
|
1553
|
+
const readingOrder = readingOrderParts.length > 0 ? `Start with ${readingOrderParts.join(", then ")}.` : "Read all available files to understand the spec requirements.";
|
|
1554
|
+
const stackNote = coreFiles.some((f) => f.startsWith("stdlib/")) ? "\nRead `stdlib/STACK.md` for technology and coding standards before writing any code." : "";
|
|
1555
|
+
return [
|
|
1556
|
+
preamble(sidecar),
|
|
1557
|
+
"",
|
|
1558
|
+
"## Spec Format: SpecMarket (Native)",
|
|
1559
|
+
"",
|
|
1560
|
+
"## Files to Read First",
|
|
1561
|
+
"",
|
|
1562
|
+
fileList,
|
|
1563
|
+
"",
|
|
1564
|
+
readingOrder + stackNote,
|
|
1565
|
+
"",
|
|
1566
|
+
"## Finding Tasks",
|
|
1567
|
+
"",
|
|
1568
|
+
"Tasks are tracked in `TASKS.md` using this format:",
|
|
1569
|
+
"",
|
|
1570
|
+
"```",
|
|
1571
|
+
"- [ ] Incomplete task",
|
|
1572
|
+
"- [x] Completed task",
|
|
1573
|
+
"```",
|
|
1574
|
+
"",
|
|
1575
|
+
coreFiles.includes("SPEC.md") ? "If `TASKS.md` does not exist, derive tasks from `SPEC.md` and `SUCCESS_CRITERIA.md`." : "If `TASKS.md` does not exist, derive tasks from the available spec files.",
|
|
1576
|
+
"Work through tasks from top to bottom. Complete each fully before moving to the next.",
|
|
1577
|
+
"",
|
|
1578
|
+
"## Marking Tasks Complete",
|
|
1579
|
+
"",
|
|
1580
|
+
"When a task is done, update `TASKS.md`:",
|
|
1581
|
+
"- Change `- [ ]` to `- [x]` for the completed task.",
|
|
1582
|
+
"",
|
|
1583
|
+
"## Completion Criteria",
|
|
1584
|
+
"",
|
|
1585
|
+
"The run is complete when ALL of the following are true:",
|
|
1586
|
+
"",
|
|
1587
|
+
"1. **All tasks checked** \u2014 No `- [ ]` lines remain in `TASKS.md`.",
|
|
1588
|
+
"2. **Tests pass** \u2014 Run the test suite; all tests green.",
|
|
1589
|
+
"3. **Success criteria met** \u2014 Every criterion in `SUCCESS_CRITERIA.md` is checked `- [x]`.",
|
|
1590
|
+
" Update each criterion in `SUCCESS_CRITERIA.md` as it is satisfied.",
|
|
1591
|
+
"",
|
|
1592
|
+
testingSection(),
|
|
1593
|
+
"",
|
|
1594
|
+
completionReminder()
|
|
1595
|
+
].join("\n");
|
|
1596
|
+
}
|
|
1597
|
+
async function generateSpeckitInstructions(dir, sidecar) {
|
|
1598
|
+
const hasTasksMd = await fileExists(join5(dir, "tasks.md"));
|
|
1599
|
+
const hasPlanMd = await fileExists(join5(dir, "plan.md"));
|
|
1600
|
+
const hasSpecifyDir = await directoryExists(join5(dir, ".specify"));
|
|
1601
|
+
const taskFile = hasTasksMd ? "tasks.md" : hasPlanMd ? "plan.md" : "tasks.md";
|
|
1602
|
+
const knownFiles = await presentFiles(dir, [
|
|
1603
|
+
"spec.md",
|
|
1604
|
+
"tasks.md",
|
|
1605
|
+
"plan.md",
|
|
1606
|
+
"requirements.md",
|
|
1607
|
+
"README.md"
|
|
1608
|
+
]);
|
|
1609
|
+
const fileList = knownFiles.length > 0 ? knownFiles.map((f) => `- \`${f}\``).join("\n") : "- `spec.md` \u2014 primary specification";
|
|
1610
|
+
const specifyNote = hasSpecifyDir ? "\nThe `.specify/` directory contains additional context files. Read them for implementation details.\n" : "";
|
|
1611
|
+
return [
|
|
1612
|
+
preamble(sidecar),
|
|
1613
|
+
"",
|
|
1614
|
+
"## Spec Format: Spec Kit",
|
|
1615
|
+
"",
|
|
1616
|
+
"## Files to Read First",
|
|
1617
|
+
"",
|
|
1618
|
+
fileList,
|
|
1619
|
+
specifyNote,
|
|
1620
|
+
"Start with `spec.md` for the full specification.",
|
|
1621
|
+
`Then read \`${taskFile}\` to find your task list.`,
|
|
1622
|
+
"",
|
|
1623
|
+
"## Finding Tasks",
|
|
1624
|
+
"",
|
|
1625
|
+
`Tasks are listed in \`${taskFile}\`. Look for checkbox items:`,
|
|
1626
|
+
"",
|
|
1627
|
+
"```",
|
|
1628
|
+
"- [ ] Incomplete task",
|
|
1629
|
+
"- [x] Completed task",
|
|
1630
|
+
"```",
|
|
1631
|
+
"",
|
|
1632
|
+
"If the task file uses numbered lists or headings instead of checkboxes,",
|
|
1633
|
+
"treat each actionable item as a task. Work through them in order.",
|
|
1634
|
+
"",
|
|
1635
|
+
"## Marking Tasks Complete",
|
|
1636
|
+
"",
|
|
1637
|
+
`When a task is done, update \`${taskFile}\`:`,
|
|
1638
|
+
"- Change `- [ ]` to `- [x]` for the completed task.",
|
|
1639
|
+
"- If using a different format, add `[DONE]` next to the completed item.",
|
|
1640
|
+
"",
|
|
1641
|
+
"## Completion Criteria",
|
|
1642
|
+
"",
|
|
1643
|
+
"The run is complete when:",
|
|
1644
|
+
"",
|
|
1645
|
+
`1. **All tasks checked** \u2014 No unchecked \`- [ ]\` items remain in \`${taskFile}\`.`,
|
|
1646
|
+
"2. **Tests pass** \u2014 Run the test suite; all tests green.",
|
|
1647
|
+
"3. **Spec satisfied** \u2014 Implementation matches all requirements in `spec.md`.",
|
|
1648
|
+
"",
|
|
1649
|
+
testingSection(),
|
|
1650
|
+
"",
|
|
1651
|
+
completionReminder()
|
|
1652
|
+
].join("\n");
|
|
1653
|
+
}
|
|
1654
|
+
async function generateBmadInstructions(dir, sidecar) {
|
|
1655
|
+
const storyFiles = await listStoryFiles(dir);
|
|
1656
|
+
const coreFiles = await presentFiles(dir, ["prd.md", "architecture.md", "epic.md"]);
|
|
1657
|
+
const coreFileList = coreFiles.map((f) => `- \`${f}\``).join("\n") || "- *(no core files found)*";
|
|
1658
|
+
const storySection = storyFiles.length > 0 ? [
|
|
1659
|
+
"**Story files found:**",
|
|
1660
|
+
...storyFiles.map((f) => `- \`${f}\``)
|
|
1661
|
+
].join("\n") : "*(No story-*.md files found \u2014 check prd.md for tasks)*";
|
|
1662
|
+
return [
|
|
1663
|
+
preamble(sidecar),
|
|
1664
|
+
"",
|
|
1665
|
+
"## Spec Format: BMAD Method",
|
|
1666
|
+
"",
|
|
1667
|
+
"## Files to Read First",
|
|
1668
|
+
"",
|
|
1669
|
+
coreFileList,
|
|
1670
|
+
"",
|
|
1671
|
+
"Start with `prd.md` for product requirements. Read `architecture.md` for technical design.",
|
|
1672
|
+
"Then read each story file in order.",
|
|
1673
|
+
"",
|
|
1674
|
+
"## Finding Tasks",
|
|
1675
|
+
"",
|
|
1676
|
+
"Work is organized as user stories in `story-*.md` files:",
|
|
1677
|
+
"",
|
|
1678
|
+
storySection,
|
|
1679
|
+
"",
|
|
1680
|
+
"Each story file contains:",
|
|
1681
|
+
"- Story description and goal",
|
|
1682
|
+
"- Acceptance criteria (what must be true for the story to be complete)",
|
|
1683
|
+
"- Technical notes",
|
|
1684
|
+
"",
|
|
1685
|
+
"If no story files exist, derive tasks from `prd.md`.",
|
|
1686
|
+
"",
|
|
1687
|
+
"## Marking Tasks Complete",
|
|
1688
|
+
"",
|
|
1689
|
+
"For each story:",
|
|
1690
|
+
"1. Implement everything required by the acceptance criteria.",
|
|
1691
|
+
"2. Mark each acceptance criterion as met in the story file by checking it: `- [ ]` \u2192 `- [x]`.",
|
|
1692
|
+
"3. Add a `## Status: Done` line at the top of the story file when all criteria are met.",
|
|
1693
|
+
"",
|
|
1694
|
+
"If working from `prd.md` directly, add a `Done:` checklist section as you complete items.",
|
|
1695
|
+
"",
|
|
1696
|
+
"## Completion Criteria",
|
|
1697
|
+
"",
|
|
1698
|
+
"The run is complete when:",
|
|
1699
|
+
"",
|
|
1700
|
+
"1. **All stories done** \u2014 Every `story-*.md` has `Status: Done` and all acceptance criteria checked.",
|
|
1701
|
+
" (Or: all tasks in `prd.md` implemented if no story files.)",
|
|
1702
|
+
"2. **Tests pass** \u2014 Run the test suite; all tests green.",
|
|
1703
|
+
"3. **Architecture followed** \u2014 Implementation matches the design in `architecture.md`.",
|
|
1704
|
+
"",
|
|
1705
|
+
testingSection(),
|
|
1706
|
+
"",
|
|
1707
|
+
completionReminder()
|
|
1708
|
+
].join("\n");
|
|
1709
|
+
}
|
|
1710
|
+
async function generateRalphInstructions(dir, sidecar) {
|
|
1711
|
+
let userStoryList = "";
|
|
1712
|
+
try {
|
|
1713
|
+
const raw = await readFile4(join5(dir, "prd.json"), "utf-8");
|
|
1714
|
+
const data = JSON.parse(raw);
|
|
1715
|
+
if (data && typeof data === "object" && "userStories" in data && Array.isArray(data.userStories)) {
|
|
1716
|
+
const stories = data.userStories;
|
|
1717
|
+
const titles = stories.map((s, i) => {
|
|
1718
|
+
const title = typeof s["title"] === "string" ? s["title"] : typeof s["name"] === "string" ? s["name"] : `Story ${i + 1}`;
|
|
1719
|
+
return `- [ ] ${title}`;
|
|
1720
|
+
}).join("\n");
|
|
1721
|
+
userStoryList = titles ? `
|
|
1722
|
+
**User stories in prd.json:**
|
|
1723
|
+
|
|
1724
|
+
${titles}
|
|
1725
|
+
` : "";
|
|
1726
|
+
}
|
|
1727
|
+
} catch {
|
|
1728
|
+
}
|
|
1729
|
+
const extraFiles = await presentFiles(dir, [
|
|
1730
|
+
"architecture.md",
|
|
1731
|
+
"README.md",
|
|
1732
|
+
"CONTRIBUTING.md"
|
|
1733
|
+
]);
|
|
1734
|
+
const extraFileList = extraFiles.length > 0 ? "\n**Additional files:**\n" + extraFiles.map((f) => `- \`${f}\``).join("\n") : "";
|
|
1735
|
+
return [
|
|
1736
|
+
preamble(sidecar),
|
|
1737
|
+
"",
|
|
1738
|
+
"## Spec Format: Ralph",
|
|
1739
|
+
"",
|
|
1740
|
+
"## Files to Read First",
|
|
1741
|
+
"",
|
|
1742
|
+
"- `prd.json` \u2014 Product requirements document with user stories",
|
|
1743
|
+
extraFileList,
|
|
1744
|
+
"",
|
|
1745
|
+
"Read `prd.json` carefully. The `userStories` array defines all the work to be done.",
|
|
1746
|
+
"",
|
|
1747
|
+
"## Finding Tasks",
|
|
1748
|
+
"",
|
|
1749
|
+
"Open `prd.json` and read the `userStories` array. Each entry is a task.",
|
|
1750
|
+
userStoryList,
|
|
1751
|
+
"Each user story typically has:",
|
|
1752
|
+
"- `title` or `name` \u2014 what to build",
|
|
1753
|
+
"- `description` or `details` \u2014 how to build it",
|
|
1754
|
+
"- `acceptanceCriteria` \u2014 what must be true for the story to be complete",
|
|
1755
|
+
"",
|
|
1756
|
+
"## Marking Tasks Complete",
|
|
1757
|
+
"",
|
|
1758
|
+
"Ralph format does not have a built-in task-tracking file.",
|
|
1759
|
+
"Create a `PROGRESS.md` file in the working directory and track progress there:",
|
|
1760
|
+
"",
|
|
1761
|
+
"```markdown",
|
|
1762
|
+
"# Progress",
|
|
1763
|
+
"",
|
|
1764
|
+
"- [x] Story title (completed)",
|
|
1765
|
+
"- [ ] Next story title",
|
|
1766
|
+
"```",
|
|
1767
|
+
"",
|
|
1768
|
+
"Update `PROGRESS.md` as you complete each user story.",
|
|
1769
|
+
"",
|
|
1770
|
+
"## Completion Criteria",
|
|
1771
|
+
"",
|
|
1772
|
+
"The run is complete when:",
|
|
1773
|
+
"",
|
|
1774
|
+
"1. **All user stories implemented** \u2014 Every story in `prd.json` has a working implementation.",
|
|
1775
|
+
"2. **All acceptance criteria met** \u2014 Verify each story's `acceptanceCriteria` is satisfied.",
|
|
1776
|
+
"3. **Tests pass** \u2014 Run the test suite; all tests green.",
|
|
1777
|
+
"4. **PROGRESS.md updated** \u2014 All stories checked `[x]` in `PROGRESS.md`.",
|
|
1778
|
+
"",
|
|
1779
|
+
testingSection(),
|
|
1780
|
+
"",
|
|
1781
|
+
completionReminder()
|
|
1782
|
+
].join("\n");
|
|
1783
|
+
}
|
|
1784
|
+
async function generateCustomInstructions(dir, sidecar) {
|
|
1785
|
+
let mdFiles = [];
|
|
1786
|
+
try {
|
|
1787
|
+
const entries = await readdir4(dir, { withFileTypes: true });
|
|
1788
|
+
mdFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
|
|
1789
|
+
} catch {
|
|
1790
|
+
}
|
|
1791
|
+
const mdFileList = mdFiles.length > 0 ? mdFiles.map((f) => `- \`${f}\``).join("\n") : "- *(no .md files found at top level)*";
|
|
1792
|
+
return [
|
|
1793
|
+
preamble(sidecar),
|
|
1794
|
+
"",
|
|
1795
|
+
"## Spec Format: Custom",
|
|
1796
|
+
"",
|
|
1797
|
+
"## Files to Read First",
|
|
1798
|
+
"",
|
|
1799
|
+
"Markdown files found in this spec:",
|
|
1800
|
+
"",
|
|
1801
|
+
mdFileList,
|
|
1802
|
+
"",
|
|
1803
|
+
"Read all markdown files to understand the full scope of work.",
|
|
1804
|
+
"Look for a README, specification, or requirements document as your primary source of truth.",
|
|
1805
|
+
"",
|
|
1806
|
+
"## Finding Tasks",
|
|
1807
|
+
"",
|
|
1808
|
+
"This spec uses a custom format. Scan all markdown files for:",
|
|
1809
|
+
"",
|
|
1810
|
+
"1. **Checkbox items**: `- [ ] task description` \u2014 these are explicit tasks",
|
|
1811
|
+
"2. **Numbered lists**: actionable steps in requirements documents",
|
|
1812
|
+
"3. **Heading-based sections**: major features or modules to implement",
|
|
1813
|
+
"",
|
|
1814
|
+
"If you find a file that looks like a task list (TASKS.md, TODO.md, checklist.md, etc.), use it.",
|
|
1815
|
+
"",
|
|
1816
|
+
"## Marking Tasks Complete",
|
|
1817
|
+
"",
|
|
1818
|
+
"If the spec has checkboxes (`- [ ]`), update them to `- [x]` as you complete tasks.",
|
|
1819
|
+
"",
|
|
1820
|
+
"If the spec has no checkboxes, create a `PROGRESS.md` file to track progress:",
|
|
1821
|
+
"",
|
|
1822
|
+
"```markdown",
|
|
1823
|
+
"# Progress",
|
|
1824
|
+
"",
|
|
1825
|
+
"- [x] Task or feature completed",
|
|
1826
|
+
"- [ ] Next task",
|
|
1827
|
+
"```",
|
|
1828
|
+
"",
|
|
1829
|
+
"## Completion Criteria",
|
|
1830
|
+
"",
|
|
1831
|
+
"The run is complete when:",
|
|
1832
|
+
"",
|
|
1833
|
+
"1. **All tasks done** \u2014 All checkbox items checked, or all items in `PROGRESS.md` checked.",
|
|
1834
|
+
"2. **Tests pass** \u2014 Run the test suite; all tests green.",
|
|
1835
|
+
"3. **Requirements satisfied** \u2014 Implementation matches all requirements found in the spec files.",
|
|
1836
|
+
"",
|
|
1837
|
+
testingSection(),
|
|
1838
|
+
"",
|
|
1839
|
+
completionReminder()
|
|
1840
|
+
].join("\n");
|
|
1841
|
+
}
|
|
1842
|
+
async function generateMetaInstructions(specDir, format) {
|
|
1843
|
+
const sidecar = await readSidecarData(specDir);
|
|
1844
|
+
switch (format) {
|
|
1845
|
+
case "specmarket":
|
|
1846
|
+
return generateSpecmarketInstructions(specDir, sidecar);
|
|
1847
|
+
case "speckit":
|
|
1848
|
+
return generateSpeckitInstructions(specDir, sidecar);
|
|
1849
|
+
case "bmad":
|
|
1850
|
+
return generateBmadInstructions(specDir, sidecar);
|
|
1851
|
+
case "ralph":
|
|
1852
|
+
return generateRalphInstructions(specDir, sidecar);
|
|
1853
|
+
case "custom":
|
|
1854
|
+
default:
|
|
1855
|
+
return generateCustomInstructions(specDir, sidecar);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// src/lib/ralph-loop.ts
|
|
901
1860
|
var debug6 = createDebug6("specmarket:runner");
|
|
902
1861
|
var execAsync = promisify(exec);
|
|
1862
|
+
async function checkClaudeCliInstalled(harness) {
|
|
1863
|
+
const h = harness ?? DEFAULT_HARNESS;
|
|
1864
|
+
const binaryName = HARNESS_BINARY[h] ?? "claude";
|
|
1865
|
+
try {
|
|
1866
|
+
await execAsync(`which ${binaryName}`);
|
|
1867
|
+
} catch {
|
|
1868
|
+
const installHint = HARNESS_INSTALL_HINT[h] ?? `Install ${binaryName} and ensure it is in your PATH.`;
|
|
1869
|
+
throw new Error(
|
|
1870
|
+
`Harness "${h}" binary "${binaryName}" is not installed or not in your PATH.
|
|
1871
|
+
|
|
1872
|
+
${installHint}
|
|
1873
|
+
`
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
var HARNESS_BINARY = {
|
|
1878
|
+
"claude-code": "claude",
|
|
1879
|
+
"codex": "codex",
|
|
1880
|
+
"opencode": "opencode"
|
|
1881
|
+
};
|
|
1882
|
+
var HARNESS_INSTALL_HINT = {
|
|
1883
|
+
"claude-code": "Installation instructions:\n npm install -g @anthropic-ai/claude-code\n\nOr visit: https://www.anthropic.com/claude-code",
|
|
1884
|
+
"codex": "Installation instructions:\n npm install -g @openai/codex\n\nOr visit: https://github.com/openai/codex",
|
|
1885
|
+
"opencode": "Installation instructions:\n npm install -g opencode-ai\n\nOr visit: https://opencode.ai"
|
|
1886
|
+
};
|
|
903
1887
|
async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
904
1888
|
const maxLoops = opts.maxLoops ?? RUN_DEFAULTS.MAX_LOOPS;
|
|
905
1889
|
const budgetTokens = opts.maxBudgetUsd ? opts.maxBudgetUsd / specYaml.estimatedCostUsd * specYaml.estimatedTokens : specYaml.estimatedTokens * RUN_DEFAULTS.BUDGET_MULTIPLIER;
|
|
1890
|
+
const harness = opts.harness ?? DEFAULT_HARNESS;
|
|
906
1891
|
const runId = opts.resumeRunId ?? randomUUID();
|
|
907
|
-
const runsBaseDir =
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
1892
|
+
const runsBaseDir = join6(homedir2(), CONFIG_PATHS.RUNS_DIR);
|
|
1893
|
+
const usingWorkdir = opts.workdir !== void 0;
|
|
1894
|
+
const runDir = opts.workdir ?? opts.outputDir ?? join6(runsBaseDir, runId);
|
|
1895
|
+
const environmentType = usingWorkdir ? "existing" : "fresh";
|
|
1896
|
+
if (!usingWorkdir) {
|
|
1897
|
+
await mkdir3(runDir, { recursive: true });
|
|
1898
|
+
}
|
|
1899
|
+
debug6("Run directory: %s (environmentType=%s, harness=%s)", runDir, environmentType, harness);
|
|
911
1900
|
if (opts.dryRun) {
|
|
912
1901
|
debug6("Dry run mode \u2014 skipping execution");
|
|
913
1902
|
const report2 = {
|
|
@@ -915,6 +1904,11 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
915
1904
|
specVersion: specYaml.version,
|
|
916
1905
|
model: opts.model ?? specYaml.minModel,
|
|
917
1906
|
runner: specYaml.runner,
|
|
1907
|
+
harness,
|
|
1908
|
+
specFormat: opts.specFormat,
|
|
1909
|
+
environmentType,
|
|
1910
|
+
steeringActionCount: 0,
|
|
1911
|
+
isPureRun: false,
|
|
918
1912
|
loopCount: 0,
|
|
919
1913
|
totalTokens: 0,
|
|
920
1914
|
totalCostUsd: 0,
|
|
@@ -937,8 +1931,13 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
937
1931
|
totalTokens = existingReport.totalTokens;
|
|
938
1932
|
debug6("Resuming from iteration %d with %d tokens carried over", startIteration, totalTokens);
|
|
939
1933
|
}
|
|
1934
|
+
await ensureMetaInstructions(specDir, runDir, opts.specFormat);
|
|
1935
|
+
} else if (usingWorkdir) {
|
|
1936
|
+
await ensureMetaInstructions(specDir, runDir, opts.specFormat);
|
|
1937
|
+
await initGit(runDir);
|
|
940
1938
|
} else {
|
|
941
1939
|
await copySpecFiles(specDir, runDir);
|
|
1940
|
+
await ensureMetaInstructions(specDir, runDir, opts.specFormat);
|
|
942
1941
|
await initGit(runDir);
|
|
943
1942
|
}
|
|
944
1943
|
const startTime = Date.now();
|
|
@@ -946,14 +1945,24 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
946
1945
|
let consecutiveNoChange = 0;
|
|
947
1946
|
let lastOutput = "";
|
|
948
1947
|
let consecutiveSameOutput = 0;
|
|
1948
|
+
const steeringLog = [];
|
|
1949
|
+
let steeringActionCount = 0;
|
|
1950
|
+
let testPhaseAttempts = 0;
|
|
949
1951
|
let finalStatus = "failure";
|
|
950
1952
|
let successCriteriaResults = [];
|
|
951
1953
|
for (let i = startIteration; i <= maxLoops; i++) {
|
|
952
1954
|
debug6("Starting loop iteration %d/%d", i, maxLoops);
|
|
953
1955
|
const iterStart = Date.now();
|
|
954
|
-
const
|
|
1956
|
+
const pendingMessages = opts.steeringQueue ? opts.steeringQueue.splice(0) : [];
|
|
1957
|
+
if (pendingMessages.length > 0) {
|
|
1958
|
+
await injectSteeringMessages(runDir, pendingMessages, steeringLog);
|
|
1959
|
+
steeringActionCount += pendingMessages.length;
|
|
1960
|
+
debug6("Injected %d steering message(s); total steeringActionCount=%d", pendingMessages.length, steeringActionCount);
|
|
1961
|
+
}
|
|
1962
|
+
const result = await executeHarness(runDir, harness, opts.model);
|
|
955
1963
|
const iterDuration = Date.now() - iterStart;
|
|
956
|
-
const
|
|
1964
|
+
const activeModel = opts.model ?? specYaml.minModel;
|
|
1965
|
+
const tokensThisLoop = parseTokensFromOutput(result.stdout, activeModel);
|
|
957
1966
|
totalTokens += tokensThisLoop;
|
|
958
1967
|
const gitDiff = await getGitDiff(runDir);
|
|
959
1968
|
const iteration = {
|
|
@@ -967,7 +1976,7 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
967
1976
|
iterations.push(iteration);
|
|
968
1977
|
onProgress?.(iteration);
|
|
969
1978
|
await writeFile3(
|
|
970
|
-
|
|
1979
|
+
join6(runDir, `iteration-${i}.json`),
|
|
971
1980
|
JSON.stringify(iteration, null, 2)
|
|
972
1981
|
);
|
|
973
1982
|
await stageAllChanges(runDir);
|
|
@@ -999,26 +2008,70 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
999
2008
|
consecutiveSameOutput = 0;
|
|
1000
2009
|
lastOutput = currentOutputHash;
|
|
1001
2010
|
}
|
|
1002
|
-
const
|
|
1003
|
-
if (
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
2011
|
+
const tasksComplete = await isFixPlanEmpty(runDir);
|
|
2012
|
+
if (tasksComplete) {
|
|
2013
|
+
const testResult = await runTestsWithOutput(runDir);
|
|
2014
|
+
if (!testResult.passed) {
|
|
2015
|
+
testPhaseAttempts++;
|
|
2016
|
+
debug6(
|
|
2017
|
+
"Post-task test phase attempt %d/%d: tests failing, writing fix tasks",
|
|
2018
|
+
testPhaseAttempts,
|
|
2019
|
+
RUN_DEFAULTS.TEST_PHASE_MAX_ITERATIONS
|
|
2020
|
+
);
|
|
2021
|
+
if (testPhaseAttempts >= RUN_DEFAULTS.TEST_PHASE_MAX_ITERATIONS) {
|
|
2022
|
+
debug6(
|
|
2023
|
+
"Test phase exceeded max iterations (%d), declaring failure",
|
|
2024
|
+
RUN_DEFAULTS.TEST_PHASE_MAX_ITERATIONS
|
|
2025
|
+
);
|
|
2026
|
+
successCriteriaResults = await evaluateSuccessCriteria(runDir).catch(() => []);
|
|
2027
|
+
finalStatus = "failure";
|
|
2028
|
+
break;
|
|
2029
|
+
}
|
|
2030
|
+
await writeTestFixTasks(runDir, testResult.output);
|
|
2031
|
+
await stageAllChanges(runDir);
|
|
2032
|
+
successCriteriaResults = await evaluateSuccessCriteria(runDir).catch(() => []);
|
|
2033
|
+
} else {
|
|
2034
|
+
const criteriaResults = await evaluateSuccessCriteria(runDir);
|
|
2035
|
+
successCriteriaResults = criteriaResults;
|
|
2036
|
+
if (criteriaResults.every((r) => r.passed)) {
|
|
2037
|
+
debug6("All tasks done, tests pass, criteria met at iteration %d", i);
|
|
2038
|
+
finalStatus = "success";
|
|
2039
|
+
break;
|
|
2040
|
+
}
|
|
2041
|
+
debug6(
|
|
2042
|
+
"Tests pass but not all criteria met at iteration %d; continuing",
|
|
2043
|
+
i
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
} else {
|
|
2047
|
+
successCriteriaResults = await evaluateSuccessCriteria(runDir).catch(() => []);
|
|
1008
2048
|
}
|
|
1009
|
-
successCriteriaResults = completionCheck.results;
|
|
1010
2049
|
}
|
|
1011
2050
|
if (finalStatus === "failure" && successCriteriaResults.length === 0) {
|
|
1012
2051
|
successCriteriaResults = await evaluateSuccessCriteria(runDir).catch(() => []);
|
|
1013
2052
|
}
|
|
2053
|
+
if (steeringLog.length > 0) {
|
|
2054
|
+
await writeFile3(
|
|
2055
|
+
join6(runDir, "steering-log.json"),
|
|
2056
|
+
JSON.stringify(steeringLog, null, 2),
|
|
2057
|
+
"utf-8"
|
|
2058
|
+
);
|
|
2059
|
+
debug6("Steering log written (%d entries)", steeringLog.length);
|
|
2060
|
+
}
|
|
1014
2061
|
const totalTimeMinutes = (Date.now() - startTime) / 6e4;
|
|
1015
2062
|
const costPerToken = specYaml.estimatedCostUsd / specYaml.estimatedTokens;
|
|
1016
2063
|
const totalCostUsd = totalTokens * costPerToken;
|
|
2064
|
+
const detectedSpecFormat = opts.specFormat ?? (await detectSpecFormat(runDir)).format;
|
|
1017
2065
|
const report = {
|
|
1018
2066
|
runId,
|
|
1019
2067
|
specVersion: specYaml.version,
|
|
1020
2068
|
model: opts.model ?? specYaml.minModel,
|
|
1021
2069
|
runner: specYaml.runner,
|
|
2070
|
+
harness,
|
|
2071
|
+
specFormat: detectedSpecFormat,
|
|
2072
|
+
environmentType,
|
|
2073
|
+
steeringActionCount,
|
|
2074
|
+
isPureRun: finalStatus === "success" && steeringActionCount === 0,
|
|
1022
2075
|
loopCount: iterations.length,
|
|
1023
2076
|
totalTokens,
|
|
1024
2077
|
totalCostUsd,
|
|
@@ -1030,15 +2083,45 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
1030
2083
|
cliVersion: opts.cliVersion
|
|
1031
2084
|
};
|
|
1032
2085
|
await writeFile3(
|
|
1033
|
-
|
|
2086
|
+
join6(runDir, "run-report.json"),
|
|
1034
2087
|
JSON.stringify(report, null, 2)
|
|
1035
2088
|
);
|
|
1036
2089
|
debug6("Run complete: %s (status=%s, loops=%d)", runId, finalStatus, iterations.length);
|
|
1037
2090
|
return { report, outputDir: runDir };
|
|
1038
2091
|
}
|
|
2092
|
+
async function ensureMetaInstructions(specDir, runDir, formatOverride) {
|
|
2093
|
+
const format = formatOverride ?? (await detectSpecFormat(specDir)).format;
|
|
2094
|
+
debug6("Generating meta-instructions for format=%s", format);
|
|
2095
|
+
const content = await generateMetaInstructions(specDir, format);
|
|
2096
|
+
await writeFile3(join6(runDir, META_INSTRUCTION_FILENAME), content, "utf-8");
|
|
2097
|
+
debug6("Meta-instructions written to %s/%s", runDir, META_INSTRUCTION_FILENAME);
|
|
2098
|
+
}
|
|
2099
|
+
async function injectSteeringMessages(runDir, messages, steeringLog) {
|
|
2100
|
+
if (messages.length === 0) return;
|
|
2101
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2102
|
+
const entries = messages.map((content) => ({ timestamp, content }));
|
|
2103
|
+
steeringLog.push(...entries);
|
|
2104
|
+
const steeringSection = [
|
|
2105
|
+
"",
|
|
2106
|
+
`## Steering Input (injected at ${timestamp})`,
|
|
2107
|
+
"",
|
|
2108
|
+
"The user has provided the following steering instructions. Incorporate them into your current work:",
|
|
2109
|
+
"",
|
|
2110
|
+
...messages.map((m) => `> ${m}`),
|
|
2111
|
+
""
|
|
2112
|
+
].join("\n");
|
|
2113
|
+
const metaPath = join6(runDir, META_INSTRUCTION_FILENAME);
|
|
2114
|
+
try {
|
|
2115
|
+
const existing = await readFile5(metaPath, "utf-8");
|
|
2116
|
+
await writeFile3(metaPath, existing + steeringSection, "utf-8");
|
|
2117
|
+
} catch {
|
|
2118
|
+
await writeFile3(metaPath, steeringSection, "utf-8");
|
|
2119
|
+
}
|
|
2120
|
+
debug6("injectSteeringMessages: appended %d message(s) to %s", messages.length, META_INSTRUCTION_FILENAME);
|
|
2121
|
+
}
|
|
1039
2122
|
async function copySpecFiles(srcDir, destDir) {
|
|
1040
2123
|
const { cp } = await import("fs/promises");
|
|
1041
|
-
await cp(srcDir,
|
|
2124
|
+
await cp(srcDir, join6(destDir, "spec"), { recursive: true });
|
|
1042
2125
|
await cp(srcDir, destDir, { recursive: true, force: false });
|
|
1043
2126
|
debug6("Spec files copied from %s to %s", srcDir, destDir);
|
|
1044
2127
|
}
|
|
@@ -1066,36 +2149,54 @@ async function getGitDiff(dir) {
|
|
|
1066
2149
|
return "";
|
|
1067
2150
|
}
|
|
1068
2151
|
}
|
|
1069
|
-
|
|
2152
|
+
function buildHarnessCommand(harness, model) {
|
|
2153
|
+
switch (harness) {
|
|
2154
|
+
case "claude-code": {
|
|
2155
|
+
const args = ["--print", "--output-format", "json"];
|
|
2156
|
+
if (model) args.push("--model", model);
|
|
2157
|
+
return `cat ${META_INSTRUCTION_FILENAME} | claude ${args.join(" ")}`;
|
|
2158
|
+
}
|
|
2159
|
+
case "codex":
|
|
2160
|
+
return `cat ${META_INSTRUCTION_FILENAME} | codex`;
|
|
2161
|
+
case "opencode":
|
|
2162
|
+
return `cat ${META_INSTRUCTION_FILENAME} | opencode`;
|
|
2163
|
+
default:
|
|
2164
|
+
debug6('Unknown harness "%s" \u2014 falling back to claude-code', harness);
|
|
2165
|
+
return `cat ${META_INSTRUCTION_FILENAME} | claude --print --output-format json`;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
async function executeHarness(dir, harness, model) {
|
|
2169
|
+
const cmd = buildHarnessCommand(harness, model);
|
|
2170
|
+
debug6("executeHarness: %s (harness=%s)", cmd, harness);
|
|
1070
2171
|
return new Promise((resolve7) => {
|
|
1071
|
-
const
|
|
1072
|
-
if (model) {
|
|
1073
|
-
args.push("--model", model);
|
|
1074
|
-
}
|
|
1075
|
-
const proc = spawn("sh", ["-c", `cat PROMPT.md | claude-code ${args.join(" ")}`], {
|
|
2172
|
+
const proc = spawn("sh", ["-c", cmd], {
|
|
1076
2173
|
cwd: dir,
|
|
1077
|
-
|
|
2174
|
+
// stdin is 'ignore': the harness reads its instructions from the meta-instructions file
|
|
2175
|
+
// via `cat .specmarket-runner.md | <harness>`, not from parent stdin.
|
|
2176
|
+
// Keeping stdin detached from the parent lets the CLI read steering messages
|
|
2177
|
+
// from process.stdin without conflict.
|
|
2178
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1078
2179
|
});
|
|
1079
2180
|
let stdout = "";
|
|
1080
|
-
let stderr = "";
|
|
1081
2181
|
proc.stdout?.on("data", (chunk) => {
|
|
1082
2182
|
stdout += chunk.toString();
|
|
1083
2183
|
});
|
|
1084
2184
|
proc.stderr?.on("data", (chunk) => {
|
|
1085
|
-
stderr += chunk.toString();
|
|
1086
2185
|
process.stderr.write(chunk);
|
|
1087
2186
|
});
|
|
1088
2187
|
proc.on("close", (code) => {
|
|
1089
2188
|
resolve7({ stdout, exitCode: code ?? 0 });
|
|
1090
2189
|
});
|
|
1091
2190
|
proc.on("error", (err) => {
|
|
1092
|
-
debug6("
|
|
2191
|
+
debug6("%s spawn error: %O", harness, err);
|
|
1093
2192
|
resolve7({ stdout: "", exitCode: 1 });
|
|
1094
2193
|
});
|
|
1095
2194
|
});
|
|
1096
2195
|
}
|
|
1097
|
-
function parseTokensFromOutput(output) {
|
|
2196
|
+
function parseTokensFromOutput(output, model) {
|
|
1098
2197
|
if (!output || output.trim().length === 0) return 0;
|
|
2198
|
+
const modelLower = (model ?? "").toLowerCase();
|
|
2199
|
+
const costPerToken = modelLower.includes("haiku") ? MODEL_COST_PER_TOKEN.haiku : modelLower.includes("opus") ? MODEL_COST_PER_TOKEN.opus : MODEL_COST_PER_TOKEN.default;
|
|
1099
2200
|
try {
|
|
1100
2201
|
const lines = output.trim().split("\n");
|
|
1101
2202
|
for (const line of lines) {
|
|
@@ -1114,7 +2215,13 @@ function parseTokensFromOutput(output) {
|
|
|
1114
2215
|
const output_tokens = parsed.usage?.output_tokens ?? parsed.usage?.completion_tokens ?? 0;
|
|
1115
2216
|
if (input > 0 || output_tokens > 0) return input + output_tokens;
|
|
1116
2217
|
if (typeof parsed.cost_usd === "number" && parsed.cost_usd > 0) {
|
|
1117
|
-
|
|
2218
|
+
debug6(
|
|
2219
|
+
"parseTokensFromOutput: using cost_usd=%f with model=%s (costPerToken=%e)",
|
|
2220
|
+
parsed.cost_usd,
|
|
2221
|
+
model ?? "unknown",
|
|
2222
|
+
costPerToken
|
|
2223
|
+
);
|
|
2224
|
+
return Math.round(parsed.cost_usd / costPerToken);
|
|
1118
2225
|
}
|
|
1119
2226
|
}
|
|
1120
2227
|
} catch {
|
|
@@ -1152,63 +2259,114 @@ function parseTokensFromOutput(output) {
|
|
|
1152
2259
|
function parseIntComma(s) {
|
|
1153
2260
|
return parseInt(s.replace(/,/g, ""), 10) || 0;
|
|
1154
2261
|
}
|
|
1155
|
-
async function checkCompletion(dir) {
|
|
1156
|
-
const fixPlanEmpty = await isFixPlanEmpty(dir);
|
|
1157
|
-
if (!fixPlanEmpty) {
|
|
1158
|
-
return {
|
|
1159
|
-
isComplete: false,
|
|
1160
|
-
results: await evaluateSuccessCriteria(dir).catch(() => [])
|
|
1161
|
-
};
|
|
1162
|
-
}
|
|
1163
|
-
const testsPass = await runTests(dir);
|
|
1164
|
-
if (!testsPass) {
|
|
1165
|
-
return {
|
|
1166
|
-
isComplete: false,
|
|
1167
|
-
results: await evaluateSuccessCriteria(dir).catch(() => [])
|
|
1168
|
-
};
|
|
1169
|
-
}
|
|
1170
|
-
const criteriaResults = await evaluateSuccessCriteria(dir);
|
|
1171
|
-
const allPassed = criteriaResults.every((r) => r.passed);
|
|
1172
|
-
return {
|
|
1173
|
-
isComplete: allPassed,
|
|
1174
|
-
results: criteriaResults
|
|
1175
|
-
};
|
|
1176
|
-
}
|
|
1177
2262
|
async function isFixPlanEmpty(dir) {
|
|
1178
2263
|
try {
|
|
1179
|
-
const content = await
|
|
2264
|
+
const content = await readFile5(join6(dir, "TASKS.md"), "utf-8");
|
|
1180
2265
|
const hasUncheckedItems = /^- \[ \]/m.test(content);
|
|
1181
2266
|
return !hasUncheckedItems;
|
|
1182
2267
|
} catch {
|
|
1183
2268
|
return true;
|
|
1184
2269
|
}
|
|
1185
2270
|
}
|
|
1186
|
-
async function
|
|
2271
|
+
async function runTestsWithOutput(dir) {
|
|
1187
2272
|
const testRunners = [
|
|
1188
|
-
{ file: "package.json", cmd: "npm test -- --run 2>&1
|
|
1189
|
-
{ file: "vitest.config.ts", cmd: "npx vitest run 2>&1
|
|
1190
|
-
{ file: "pytest.ini", cmd: "python -m pytest --tb=
|
|
1191
|
-
{ file: "Makefile", cmd: "make test 2>&1
|
|
2273
|
+
{ file: "package.json", cmd: "npm test -- --run 2>&1" },
|
|
2274
|
+
{ file: "vitest.config.ts", cmd: "npx vitest run 2>&1" },
|
|
2275
|
+
{ file: "pytest.ini", cmd: "python -m pytest --tb=short -q 2>&1" },
|
|
2276
|
+
{ file: "Makefile", cmd: "make test 2>&1" }
|
|
1192
2277
|
];
|
|
1193
2278
|
for (const runner of testRunners) {
|
|
1194
2279
|
try {
|
|
1195
|
-
await
|
|
2280
|
+
await access3(join6(dir, runner.file));
|
|
2281
|
+
} catch {
|
|
2282
|
+
continue;
|
|
2283
|
+
}
|
|
2284
|
+
try {
|
|
1196
2285
|
const { stdout, stderr } = await execAsync(runner.cmd, {
|
|
1197
2286
|
cwd: dir,
|
|
1198
2287
|
timeout: 12e4
|
|
1199
2288
|
});
|
|
1200
2289
|
const combined = stdout + stderr;
|
|
1201
|
-
const hasFailed = /\d+ failed|\d+ error
|
|
1202
|
-
return !hasFailed;
|
|
1203
|
-
} catch {
|
|
2290
|
+
const hasFailed = /\d+ failed|\d+ error/i.test(combined);
|
|
2291
|
+
return { passed: !hasFailed, output: combined };
|
|
2292
|
+
} catch (err) {
|
|
2293
|
+
if (err && typeof err === "object") {
|
|
2294
|
+
const execErr = err;
|
|
2295
|
+
if (typeof execErr.code === "number" && execErr.signal == null) {
|
|
2296
|
+
const combined = (execErr.stdout ?? "") + (execErr.stderr ?? "");
|
|
2297
|
+
return { passed: false, output: combined };
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
1204
2300
|
continue;
|
|
1205
2301
|
}
|
|
1206
2302
|
}
|
|
1207
|
-
return true;
|
|
2303
|
+
return { passed: true, output: "" };
|
|
2304
|
+
}
|
|
2305
|
+
function extractTestFailures(output) {
|
|
2306
|
+
const failures = [];
|
|
2307
|
+
const failFileMatches = output.match(/^FAIL\s+\S+/gm) ?? [];
|
|
2308
|
+
for (const m of failFileMatches) {
|
|
2309
|
+
const name = m.replace(/^FAIL\s+/, "").trim();
|
|
2310
|
+
if (name && !failures.includes(name)) failures.push(name);
|
|
2311
|
+
}
|
|
2312
|
+
const failTestMatches = output.match(/^[\s]*[×✗✕]\s+(.+)/gm) ?? [];
|
|
2313
|
+
for (const m of failTestMatches) {
|
|
2314
|
+
const name = m.replace(/^[\s]*[×✗✕]\s+/, "").trim();
|
|
2315
|
+
if (name && !failures.includes(name)) failures.push(name);
|
|
2316
|
+
}
|
|
2317
|
+
const pytestMatches = output.match(/^FAILED\s+\S+/gm) ?? [];
|
|
2318
|
+
for (const m of pytestMatches) {
|
|
2319
|
+
const name = m.replace(/^FAILED\s+/, "").trim();
|
|
2320
|
+
if (name && !failures.includes(name)) failures.push(name);
|
|
2321
|
+
}
|
|
2322
|
+
if (failures.length === 0) {
|
|
2323
|
+
const summaryMatch = output.match(/(\d+)\s+failed/i);
|
|
2324
|
+
if (summaryMatch) {
|
|
2325
|
+
failures.push(`${summaryMatch[1]} test(s) failed \u2014 see TEST_FAILURES.md for details`);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
return failures.slice(0, 10);
|
|
2329
|
+
}
|
|
2330
|
+
async function writeTestFixTasks(dir, testOutput) {
|
|
2331
|
+
await writeFile3(
|
|
2332
|
+
join6(dir, "TEST_FAILURES.md"),
|
|
2333
|
+
[
|
|
2334
|
+
"# Test Failures",
|
|
2335
|
+
"",
|
|
2336
|
+
"> Auto-generated by SpecMarket runner. Delete this file when all tests pass.",
|
|
2337
|
+
"",
|
|
2338
|
+
"## Raw Test Output",
|
|
2339
|
+
"",
|
|
2340
|
+
"```",
|
|
2341
|
+
testOutput.slice(0, 8e3),
|
|
2342
|
+
"```"
|
|
2343
|
+
].join("\n"),
|
|
2344
|
+
"utf-8"
|
|
2345
|
+
);
|
|
2346
|
+
const failures = extractTestFailures(testOutput);
|
|
2347
|
+
if (failures.length === 0) return;
|
|
2348
|
+
const testFixSection = [
|
|
2349
|
+
"",
|
|
2350
|
+
"## Test Failures (Auto-Generated)",
|
|
2351
|
+
"> These tasks were created by the runner after detecting test failures.",
|
|
2352
|
+
"> Fix each failing test, then delete this section and TEST_FAILURES.md.",
|
|
2353
|
+
"",
|
|
2354
|
+
...failures.map((f) => `- [ ] Fix: ${f}`)
|
|
2355
|
+
].join("\n");
|
|
2356
|
+
try {
|
|
2357
|
+
const existing = await readFile5(join6(dir, "TASKS.md"), "utf-8");
|
|
2358
|
+
const withoutPrevious = existing.replace(
|
|
2359
|
+
/\n## Test Failures \(Auto-Generated\)[\s\S]*/,
|
|
2360
|
+
""
|
|
2361
|
+
);
|
|
2362
|
+
await writeFile3(join6(dir, "TASKS.md"), withoutPrevious + testFixSection, "utf-8");
|
|
2363
|
+
} catch {
|
|
2364
|
+
await writeFile3(join6(dir, "TASKS.md"), `# Tasks${testFixSection}`, "utf-8");
|
|
2365
|
+
}
|
|
1208
2366
|
}
|
|
1209
2367
|
async function evaluateSuccessCriteria(dir) {
|
|
1210
2368
|
try {
|
|
1211
|
-
const content = await
|
|
2369
|
+
const content = await readFile5(join6(dir, "SUCCESS_CRITERIA.md"), "utf-8");
|
|
1212
2370
|
const lines = content.split("\n");
|
|
1213
2371
|
const results = [];
|
|
1214
2372
|
for (const line of lines) {
|
|
@@ -1227,7 +2385,7 @@ async function evaluateSuccessCriteria(dir) {
|
|
|
1227
2385
|
}
|
|
1228
2386
|
async function loadExistingReport(dir) {
|
|
1229
2387
|
try {
|
|
1230
|
-
const raw = await
|
|
2388
|
+
const raw = await readFile5(join6(dir, "run-report.json"), "utf-8");
|
|
1231
2389
|
return JSON.parse(raw);
|
|
1232
2390
|
} catch {
|
|
1233
2391
|
return null;
|
|
@@ -1265,8 +2423,8 @@ async function handleRun(specPathOrId, opts) {
|
|
|
1265
2423
|
console.log(chalk6.yellow(` \u26A0 ${warning}`));
|
|
1266
2424
|
}
|
|
1267
2425
|
}
|
|
1268
|
-
const specYamlContent = await
|
|
1269
|
-
const specYamlRaw =
|
|
2426
|
+
const specYamlContent = await readFile6(join7(specDir, "spec.yaml"), "utf-8");
|
|
2427
|
+
const specYamlRaw = parseYaml4(specYamlContent);
|
|
1270
2428
|
const specYaml = specYamlSchema.parse(specYamlRaw);
|
|
1271
2429
|
console.log("");
|
|
1272
2430
|
console.log(chalk6.yellow("\u26A0 SECURITY WARNING"));
|
|
@@ -1284,15 +2442,57 @@ async function handleRun(specPathOrId, opts) {
|
|
|
1284
2442
|
if (authed && !opts.noTelemetry) {
|
|
1285
2443
|
await promptTelemetryOptIn();
|
|
1286
2444
|
}
|
|
2445
|
+
if (opts.harness && !KNOWN_HARNESSES.includes(opts.harness)) {
|
|
2446
|
+
console.log(chalk6.red(`
|
|
2447
|
+
\u2717 Unknown harness "${opts.harness}". Supported: ${KNOWN_HARNESSES.join(", ")}`));
|
|
2448
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2449
|
+
}
|
|
2450
|
+
try {
|
|
2451
|
+
await checkClaudeCliInstalled(opts.harness);
|
|
2452
|
+
} catch (err) {
|
|
2453
|
+
console.log(chalk6.red(`
|
|
2454
|
+
\u2717 ${err.message}`));
|
|
2455
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2456
|
+
}
|
|
1287
2457
|
const maxLoops = opts.maxLoops ? parseInt(opts.maxLoops, 10) : void 0;
|
|
1288
2458
|
const maxBudget = opts.maxBudget ? parseFloat(opts.maxBudget) : void 0;
|
|
2459
|
+
const harness = opts.harness ?? "claude-code";
|
|
1289
2460
|
console.log(chalk6.cyan(`
|
|
1290
2461
|
Running spec: ${chalk6.bold(specYaml.display_name)}`));
|
|
1291
2462
|
console.log(chalk6.gray(` Version: ${specYaml.version}`));
|
|
1292
2463
|
console.log(chalk6.gray(` Model: ${opts.model ?? specYaml.min_model}`));
|
|
2464
|
+
console.log(chalk6.gray(` Harness: ${harness}`));
|
|
2465
|
+
if (opts.workdir) {
|
|
2466
|
+
console.log(chalk6.gray(` Working dir: ${opts.workdir}`));
|
|
2467
|
+
}
|
|
1293
2468
|
console.log(chalk6.gray(` Max loops: ${maxLoops ?? 50}`));
|
|
1294
2469
|
console.log(chalk6.gray(` Estimated tokens: ${specYaml.estimated_tokens.toLocaleString()}`));
|
|
1295
2470
|
console.log(chalk6.gray(` Estimated cost: $${specYaml.estimated_cost_usd.toFixed(2)}`));
|
|
2471
|
+
const steeringQueue = [];
|
|
2472
|
+
let steeringInputBuffer = "";
|
|
2473
|
+
const steeringDataHandler = (chunk) => {
|
|
2474
|
+
const data = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
2475
|
+
steeringInputBuffer += data;
|
|
2476
|
+
const lines = steeringInputBuffer.split("\n");
|
|
2477
|
+
steeringInputBuffer = lines.pop() ?? "";
|
|
2478
|
+
for (const line of lines) {
|
|
2479
|
+
const trimmed = line.trim();
|
|
2480
|
+
if (trimmed) {
|
|
2481
|
+
steeringQueue.push(trimmed);
|
|
2482
|
+
process.stderr.write(
|
|
2483
|
+
`
|
|
2484
|
+
${chalk6.cyan("[steering]")} Queued: "${trimmed.length > 60 ? trimmed.slice(0, 60) + "\u2026" : trimmed}"
|
|
2485
|
+
`
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
};
|
|
2490
|
+
if (!opts.dryRun) {
|
|
2491
|
+
process.stdin.setEncoding("utf-8");
|
|
2492
|
+
process.stdin.resume();
|
|
2493
|
+
process.stdin.on("data", steeringDataHandler);
|
|
2494
|
+
console.log(chalk6.gray(" Tip: Type a message + Enter to steer the agent mid-run."));
|
|
2495
|
+
}
|
|
1296
2496
|
console.log("");
|
|
1297
2497
|
const spinner = ora3({ text: "Starting loop iteration 1...", spinner: "dots" }).start();
|
|
1298
2498
|
try {
|
|
@@ -1314,12 +2514,17 @@ Running spec: ${chalk6.bold(specYaml.display_name)}`));
|
|
|
1314
2514
|
dryRun: opts.dryRun,
|
|
1315
2515
|
resumeRunId: opts.resume,
|
|
1316
2516
|
outputDir: opts.output,
|
|
1317
|
-
|
|
2517
|
+
harness: opts.harness,
|
|
2518
|
+
workdir: opts.workdir,
|
|
2519
|
+
cliVersion: CLI_VERSION,
|
|
2520
|
+
steeringQueue
|
|
1318
2521
|
},
|
|
1319
2522
|
(iteration) => {
|
|
1320
2523
|
spinner.text = `Loop ${iteration.iteration}: ${iteration.tokens.toLocaleString()} tokens, ${(iteration.durationMs / 1e3).toFixed(1)}s`;
|
|
1321
2524
|
}
|
|
1322
2525
|
);
|
|
2526
|
+
process.stdin.removeListener("data", steeringDataHandler);
|
|
2527
|
+
process.stdin.pause();
|
|
1323
2528
|
const { report } = result;
|
|
1324
2529
|
const statusColor = report.status === "success" ? chalk6.green : report.status === "stall" || report.status === "budget_exceeded" ? chalk6.yellow : chalk6.red;
|
|
1325
2530
|
spinner.stop();
|
|
@@ -1331,6 +2536,9 @@ Running spec: ${chalk6.bold(specYaml.display_name)}`));
|
|
|
1331
2536
|
console.log(` Tokens: ${report.totalTokens.toLocaleString()}`);
|
|
1332
2537
|
console.log(` Cost: $${report.totalCostUsd.toFixed(4)}`);
|
|
1333
2538
|
console.log(` Time: ${report.totalTimeMinutes.toFixed(1)} minutes`);
|
|
2539
|
+
if (report.steeringActionCount && report.steeringActionCount > 0) {
|
|
2540
|
+
console.log(` Steering Actions: ${report.steeringActionCount}`);
|
|
2541
|
+
}
|
|
1334
2542
|
console.log(` Run ID: ${chalk6.gray(report.runId)}`);
|
|
1335
2543
|
console.log(` Output: ${chalk6.gray(result.outputDir)}`);
|
|
1336
2544
|
if (report.successCriteriaResults.length > 0) {
|
|
@@ -1375,10 +2583,10 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1375
2583
|
debug7("Treating %s as local path (no registry pattern match)", pathOrId);
|
|
1376
2584
|
return { specDir: resolve4(pathOrId) };
|
|
1377
2585
|
}
|
|
1378
|
-
const { access:
|
|
2586
|
+
const { access: access4 } = await import("fs/promises");
|
|
1379
2587
|
const localPath = resolve4(pathOrId);
|
|
1380
2588
|
try {
|
|
1381
|
-
await
|
|
2589
|
+
await access4(localPath);
|
|
1382
2590
|
debug7("Found local directory %s \u2014 using as local spec", localPath);
|
|
1383
2591
|
return { specDir: localPath };
|
|
1384
2592
|
} catch {
|
|
@@ -1442,7 +2650,7 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1442
2650
|
debug7("Got download URL for %s@%s", scopedName, resolvedVersion);
|
|
1443
2651
|
const { tmpdir } = await import("os");
|
|
1444
2652
|
const { randomUUID: randomUUID2 } = await import("crypto");
|
|
1445
|
-
const tempDir =
|
|
2653
|
+
const tempDir = join7(tmpdir(), `specmarket-${randomUUID2()}`);
|
|
1446
2654
|
await mkdir4(tempDir, { recursive: true });
|
|
1447
2655
|
let response;
|
|
1448
2656
|
try {
|
|
@@ -1459,7 +2667,7 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1459
2667
|
throw err;
|
|
1460
2668
|
}
|
|
1461
2669
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1462
|
-
const zipPath =
|
|
2670
|
+
const zipPath = join7(tempDir, "spec.zip");
|
|
1463
2671
|
await writeFileFn(zipPath, buffer);
|
|
1464
2672
|
const { execAsync: execAsync2 } = await import("./exec-K3BOXX3C.js");
|
|
1465
2673
|
await execAsync2(`unzip -q "${zipPath}" -d "${tempDir}"`);
|
|
@@ -1471,7 +2679,13 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1471
2679
|
return { specDir: tempDir, registrySpecId };
|
|
1472
2680
|
}
|
|
1473
2681
|
function createRunCommand() {
|
|
1474
|
-
return new Command6("run").description("Execute a spec locally using the Ralph Loop").argument("
|
|
2682
|
+
return new Command6("run").description("Execute a spec locally using the Ralph Loop").argument("[path-or-id]", "Local path to spec directory or registry ID (@user/name[@version])", ".").option("--max-loops <n>", "Maximum loop iterations (default: 50)").option("--max-budget <usd>", "Maximum budget in USD (default: 2x estimated)").option("--no-telemetry", "Disable telemetry submission for this run").option("--model <model>", "Override AI model (default: spec's min_model)").option("--dry-run", "Validate and show config without executing").option("--resume <run-id>", "Resume a previous run from where it left off").option("--output <dir>", "Custom output directory for run artifacts").option(
|
|
2683
|
+
"--harness <harness>",
|
|
2684
|
+
`Agentic harness to use (default: claude-code). One of: ${KNOWN_HARNESSES.join(", ")}`
|
|
2685
|
+
).option(
|
|
2686
|
+
"--workdir <dir>",
|
|
2687
|
+
"Run in an existing directory instead of a fresh sandbox (spec files not copied)"
|
|
2688
|
+
).action(async (pathOrId, opts) => {
|
|
1475
2689
|
try {
|
|
1476
2690
|
await handleRun(pathOrId, opts);
|
|
1477
2691
|
} catch (err) {
|
|
@@ -1649,11 +2863,43 @@ async function handleInfo(specId) {
|
|
|
1649
2863
|
const spinner = (await import("ora")).default(`Loading info for ${specId}...`).start();
|
|
1650
2864
|
try {
|
|
1651
2865
|
const isScopedName = specId.startsWith("@") || specId.includes("/");
|
|
1652
|
-
const [spec, stats,
|
|
2866
|
+
const [spec, stats, versionsResult] = await Promise.all([
|
|
1653
2867
|
client.query(api2.specs.get, isScopedName ? { scopedName: specId } : { specId }),
|
|
1654
2868
|
client.query(api2.runs.getStats, { specId }).catch(() => null),
|
|
1655
|
-
client.query(api2.specs.getVersions, { specId }).catch(() => [])
|
|
2869
|
+
client.query(api2.specs.getVersions, { specId, paginationOpts: { numItems: 25, cursor: null } }).catch(() => ({ page: [] }))
|
|
1656
2870
|
]);
|
|
2871
|
+
const versions = versionsResult.page;
|
|
2872
|
+
let openIssueCount = 0;
|
|
2873
|
+
let maintainers = [];
|
|
2874
|
+
let commentCount = 0;
|
|
2875
|
+
if (spec) {
|
|
2876
|
+
const [issuesResult, maintainersResult, commentsResult] = await Promise.all([
|
|
2877
|
+
client.query(api2.issues.list, {
|
|
2878
|
+
specId: spec._id,
|
|
2879
|
+
status: "open",
|
|
2880
|
+
paginationOpts: { numItems: 1, cursor: null }
|
|
2881
|
+
}).catch(() => null),
|
|
2882
|
+
client.query(api2.specMaintainers.list, { specId: spec._id }).catch(() => []),
|
|
2883
|
+
client.query(api2.comments.list, {
|
|
2884
|
+
targetType: "spec",
|
|
2885
|
+
targetId: spec._id,
|
|
2886
|
+
paginationOpts: { numItems: 1, cursor: null }
|
|
2887
|
+
}).catch(() => null)
|
|
2888
|
+
]);
|
|
2889
|
+
if (issuesResult) {
|
|
2890
|
+
openIssueCount = issuesResult.page.length;
|
|
2891
|
+
if (!issuesResult.isDone && openIssueCount > 0) {
|
|
2892
|
+
openIssueCount = -1;
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
maintainers = maintainersResult;
|
|
2896
|
+
if (commentsResult) {
|
|
2897
|
+
commentCount = commentsResult.page.length;
|
|
2898
|
+
if (!commentsResult.isDone && commentCount > 0) {
|
|
2899
|
+
commentCount = -1;
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
1657
2903
|
spinner.stop();
|
|
1658
2904
|
if (!spec) {
|
|
1659
2905
|
console.log(chalk8.red(`Spec not found: ${specId}`));
|
|
@@ -1701,6 +2947,18 @@ async function handleInfo(specId) {
|
|
|
1701
2947
|
if (spec.forkedFromId) {
|
|
1702
2948
|
console.log(chalk8.gray(` (Forked from v${spec.forkedFromVersion})`));
|
|
1703
2949
|
}
|
|
2950
|
+
const issueDisplay = openIssueCount === -1 ? "many" : String(openIssueCount);
|
|
2951
|
+
const commentDisplay = commentCount === -1 ? "many" : String(commentCount);
|
|
2952
|
+
console.log(
|
|
2953
|
+
` Open Issues: ${issueDisplay}`
|
|
2954
|
+
);
|
|
2955
|
+
console.log(
|
|
2956
|
+
` Comments: ${commentDisplay}`
|
|
2957
|
+
);
|
|
2958
|
+
if (maintainers.length > 0) {
|
|
2959
|
+
const names = maintainers.filter((m) => m.user).map((m) => `@${m.user.username}`).join(", ");
|
|
2960
|
+
console.log(` Maintainers: ${names}`);
|
|
2961
|
+
}
|
|
1704
2962
|
if (author) {
|
|
1705
2963
|
console.log("");
|
|
1706
2964
|
console.log(chalk8.bold("Creator:"));
|
|
@@ -1785,9 +3043,9 @@ function createInfoCommand() {
|
|
|
1785
3043
|
import { Command as Command9 } from "commander";
|
|
1786
3044
|
import chalk9 from "chalk";
|
|
1787
3045
|
import ora4 from "ora";
|
|
1788
|
-
import { readFile as
|
|
1789
|
-
import { join as
|
|
1790
|
-
import { parse as
|
|
3046
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
3047
|
+
import { join as join8, resolve as resolve5 } from "path";
|
|
3048
|
+
import { parse as parseYaml5 } from "yaml";
|
|
1791
3049
|
import { createWriteStream } from "fs";
|
|
1792
3050
|
async function handlePublish(specPath, opts = {}) {
|
|
1793
3051
|
const creds = await requireAuth();
|
|
@@ -1808,8 +3066,8 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
1808
3066
|
spinner.succeed("Spec validated");
|
|
1809
3067
|
}
|
|
1810
3068
|
spinner.start("Reading spec metadata...");
|
|
1811
|
-
const specYamlContent = await
|
|
1812
|
-
const specYamlRaw =
|
|
3069
|
+
const specYamlContent = await readFile7(join8(dir, "spec.yaml"), "utf-8");
|
|
3070
|
+
const specYamlRaw = parseYaml5(specYamlContent);
|
|
1813
3071
|
const specYaml = specYamlSchema.parse(specYamlRaw);
|
|
1814
3072
|
spinner.succeed(`Loaded spec: ${specYaml.display_name} v${specYaml.version}`);
|
|
1815
3073
|
const client = await getConvexClient(creds.token);
|
|
@@ -1824,7 +3082,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
1824
3082
|
spinner.succeed("Spec archive created");
|
|
1825
3083
|
spinner.start("Uploading spec to registry...");
|
|
1826
3084
|
const uploadUrl = await client.mutation(api2.specs.generateUploadUrl, {});
|
|
1827
|
-
const zipContent = await
|
|
3085
|
+
const zipContent = await readFile7(zipPath);
|
|
1828
3086
|
const uploadResponse = await fetch(uploadUrl, {
|
|
1829
3087
|
method: "POST",
|
|
1830
3088
|
headers: { "Content-Type": "application/zip" },
|
|
@@ -1836,7 +3094,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
1836
3094
|
const { storageId } = await uploadResponse.json();
|
|
1837
3095
|
spinner.succeed("Spec uploaded");
|
|
1838
3096
|
spinner.start("Publishing to registry...");
|
|
1839
|
-
const readme = await
|
|
3097
|
+
const readme = await readFile7(join8(dir, "SPEC.md"), "utf-8").catch(() => void 0);
|
|
1840
3098
|
const publishResult = await client.mutation(api2.specs.publish, {
|
|
1841
3099
|
slug: specYaml.name,
|
|
1842
3100
|
displayName: specYaml.display_name,
|
|
@@ -1850,6 +3108,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
1850
3108
|
specStorageId: storageId,
|
|
1851
3109
|
readme,
|
|
1852
3110
|
runner: specYaml.runner,
|
|
3111
|
+
specFormat: validation.format,
|
|
1853
3112
|
minModel: specYaml.min_model,
|
|
1854
3113
|
estimatedTokens: specYaml.estimated_tokens,
|
|
1855
3114
|
estimatedCostUsd: specYaml.estimated_cost_usd,
|
|
@@ -1878,7 +3137,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
1878
3137
|
async function createSpecZip(dir) {
|
|
1879
3138
|
const { tmpdir } = await import("os");
|
|
1880
3139
|
const { randomUUID: randomUUID2 } = await import("crypto");
|
|
1881
|
-
const zipPath =
|
|
3140
|
+
const zipPath = join8(tmpdir(), `spec-${randomUUID2()}.zip`);
|
|
1882
3141
|
const archiver = (await import("archiver")).default;
|
|
1883
3142
|
const output = createWriteStream(zipPath);
|
|
1884
3143
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
@@ -1909,9 +3168,9 @@ function createPublishCommand() {
|
|
|
1909
3168
|
import { Command as Command10 } from "commander";
|
|
1910
3169
|
import chalk10 from "chalk";
|
|
1911
3170
|
import ora5 from "ora";
|
|
1912
|
-
import { mkdir as mkdir5, writeFile as writeFile4, readFile as
|
|
1913
|
-
import { join as
|
|
1914
|
-
import { parse as
|
|
3171
|
+
import { mkdir as mkdir5, writeFile as writeFile4, readFile as readFile8 } from "fs/promises";
|
|
3172
|
+
import { join as join9, resolve as resolve6 } from "path";
|
|
3173
|
+
import { parse as parseYaml6, stringify as stringifyYaml } from "yaml";
|
|
1915
3174
|
async function handleFork(specId, targetPath) {
|
|
1916
3175
|
const creds = await requireAuth();
|
|
1917
3176
|
const spinner = ora5("Loading spec info...").start();
|
|
@@ -1939,9 +3198,9 @@ async function handleFork(specId, targetPath) {
|
|
|
1939
3198
|
const targetDir = resolve6(targetPath ?? spec.slug);
|
|
1940
3199
|
spinner.text = `Extracting to ${targetDir}...`;
|
|
1941
3200
|
await downloadAndExtract(url, targetDir);
|
|
1942
|
-
const specYamlPath =
|
|
1943
|
-
const specYamlContent = await
|
|
1944
|
-
const specYamlData =
|
|
3201
|
+
const specYamlPath = join9(targetDir, "spec.yaml");
|
|
3202
|
+
const specYamlContent = await readFile8(specYamlPath, "utf-8");
|
|
3203
|
+
const specYamlData = parseYaml6(specYamlContent);
|
|
1945
3204
|
specYamlData["forked_from_id"] = spec._id;
|
|
1946
3205
|
specYamlData["forked_from_version"] = spec.currentVersion;
|
|
1947
3206
|
specYamlData["version"] = "1.0.0";
|
|
@@ -1974,7 +3233,7 @@ async function downloadAndExtract(url, targetDir) {
|
|
|
1974
3233
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1975
3234
|
const { tmpdir } = await import("os");
|
|
1976
3235
|
const { randomUUID: randomUUID2 } = await import("crypto");
|
|
1977
|
-
const zipPath =
|
|
3236
|
+
const zipPath = join9(tmpdir(), `fork-${randomUUID2()}.zip`);
|
|
1978
3237
|
const { writeFile: writeFileFn2, unlink: unlink2 } = await import("fs/promises");
|
|
1979
3238
|
await writeFileFn2(zipPath, buffer);
|
|
1980
3239
|
await mkdir5(targetDir, { recursive: true });
|
|
@@ -2001,15 +3260,15 @@ function createForkCommand() {
|
|
|
2001
3260
|
// src/commands/report.ts
|
|
2002
3261
|
import { Command as Command11 } from "commander";
|
|
2003
3262
|
import chalk11 from "chalk";
|
|
2004
|
-
import { readFile as
|
|
2005
|
-
import { join as
|
|
3263
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
3264
|
+
import { join as join10 } from "path";
|
|
2006
3265
|
import { homedir as homedir3 } from "os";
|
|
2007
3266
|
async function handleReport(runId) {
|
|
2008
|
-
const localPath =
|
|
3267
|
+
const localPath = join10(homedir3(), CONFIG_PATHS.RUNS_DIR, runId, "run-report.json");
|
|
2009
3268
|
let report = null;
|
|
2010
3269
|
let source = "local";
|
|
2011
3270
|
try {
|
|
2012
|
-
const raw = await
|
|
3271
|
+
const raw = await readFile9(localPath, "utf-8");
|
|
2013
3272
|
report = JSON.parse(raw);
|
|
2014
3273
|
source = "local";
|
|
2015
3274
|
} catch {
|
|
@@ -2257,6 +3516,438 @@ function createConfigCommand() {
|
|
|
2257
3516
|
return configCmd;
|
|
2258
3517
|
}
|
|
2259
3518
|
|
|
3519
|
+
// src/commands/issues.ts
|
|
3520
|
+
import { Command as Command13 } from "commander";
|
|
3521
|
+
import chalk13 from "chalk";
|
|
3522
|
+
import Table2 from "cli-table3";
|
|
3523
|
+
async function loadApi() {
|
|
3524
|
+
try {
|
|
3525
|
+
return (await import("./api-GIDUNUXG.js")).api;
|
|
3526
|
+
} catch {
|
|
3527
|
+
console.error(
|
|
3528
|
+
chalk13.red("Error: Could not load Convex API bindings. Is CONVEX_URL configured?")
|
|
3529
|
+
);
|
|
3530
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
async function resolveSpec(client, api2, specRef) {
|
|
3534
|
+
const isScopedName = specRef.startsWith("@") || specRef.includes("/");
|
|
3535
|
+
const spec = await client.query(
|
|
3536
|
+
api2.specs.get,
|
|
3537
|
+
isScopedName ? { scopedName: specRef } : { specId: specRef }
|
|
3538
|
+
);
|
|
3539
|
+
if (!spec) {
|
|
3540
|
+
console.error(chalk13.red(`Spec not found: ${specRef}`));
|
|
3541
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3542
|
+
}
|
|
3543
|
+
return spec;
|
|
3544
|
+
}
|
|
3545
|
+
function relativeTime(timestamp) {
|
|
3546
|
+
const diff = Date.now() - timestamp;
|
|
3547
|
+
const seconds = Math.floor(diff / 1e3);
|
|
3548
|
+
const minutes = Math.floor(seconds / 60);
|
|
3549
|
+
const hours = Math.floor(minutes / 60);
|
|
3550
|
+
const days = Math.floor(hours / 24);
|
|
3551
|
+
if (days > 0) return `${days}d ago`;
|
|
3552
|
+
if (hours > 0) return `${hours}h ago`;
|
|
3553
|
+
if (minutes > 0) return `${minutes}m ago`;
|
|
3554
|
+
return "just now";
|
|
3555
|
+
}
|
|
3556
|
+
async function handleIssuesList(specRef, opts) {
|
|
3557
|
+
const api2 = await loadApi();
|
|
3558
|
+
const client = await getConvexClient();
|
|
3559
|
+
const spinner = (await import("ora")).default("Loading issues...").start();
|
|
3560
|
+
try {
|
|
3561
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
3562
|
+
const statusFilter = opts.status === "all" ? void 0 : opts.status ?? "open";
|
|
3563
|
+
const result = await client.query(api2.issues.list, {
|
|
3564
|
+
specId: spec._id,
|
|
3565
|
+
status: statusFilter,
|
|
3566
|
+
labels: opts.label ? [opts.label] : void 0,
|
|
3567
|
+
paginationOpts: { numItems: 50, cursor: null }
|
|
3568
|
+
});
|
|
3569
|
+
spinner.stop();
|
|
3570
|
+
const issues = result.page;
|
|
3571
|
+
if (issues.length === 0) {
|
|
3572
|
+
const statusLabel = statusFilter ?? "any";
|
|
3573
|
+
console.log(chalk13.gray(`No ${statusLabel} issues for ${spec.scopedName}`));
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
console.log(
|
|
3577
|
+
chalk13.bold(`
|
|
3578
|
+
${issues.length} issue(s) for ${spec.scopedName}:
|
|
3579
|
+
`)
|
|
3580
|
+
);
|
|
3581
|
+
const table = new Table2({
|
|
3582
|
+
head: [
|
|
3583
|
+
chalk13.cyan("#"),
|
|
3584
|
+
chalk13.cyan("Title"),
|
|
3585
|
+
chalk13.cyan("Author"),
|
|
3586
|
+
chalk13.cyan("Age"),
|
|
3587
|
+
chalk13.cyan("Labels")
|
|
3588
|
+
],
|
|
3589
|
+
style: { compact: true },
|
|
3590
|
+
colWidths: [6, 40, 16, 10, 20],
|
|
3591
|
+
wordWrap: true
|
|
3592
|
+
});
|
|
3593
|
+
for (const issue of issues) {
|
|
3594
|
+
const statusIcon = issue.status === "open" ? chalk13.green("\u25CF") : chalk13.gray("\u25CB");
|
|
3595
|
+
table.push([
|
|
3596
|
+
`${statusIcon} ${issue.number}`,
|
|
3597
|
+
issue.title.slice(0, 60),
|
|
3598
|
+
issue.author ? `@${issue.author.username}` : chalk13.gray("unknown"),
|
|
3599
|
+
relativeTime(issue.createdAt),
|
|
3600
|
+
issue.labels.length > 0 ? issue.labels.join(", ") : chalk13.gray("\u2014")
|
|
3601
|
+
]);
|
|
3602
|
+
}
|
|
3603
|
+
console.log(table.toString());
|
|
3604
|
+
console.log(
|
|
3605
|
+
chalk13.gray(
|
|
3606
|
+
`
|
|
3607
|
+
View: ${chalk13.cyan(`specmarket issues ${specRef} <number>`)}`
|
|
3608
|
+
)
|
|
3609
|
+
);
|
|
3610
|
+
} catch (err) {
|
|
3611
|
+
spinner.fail(chalk13.red(`Failed to load issues: ${err.message}`));
|
|
3612
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
async function handleIssuesCreate(specRef) {
|
|
3616
|
+
const creds = await requireAuth();
|
|
3617
|
+
const api2 = await loadApi();
|
|
3618
|
+
const client = await getConvexClient(creds.token);
|
|
3619
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
3620
|
+
const { default: inquirer } = await import("inquirer");
|
|
3621
|
+
const answers = await inquirer.prompt([
|
|
3622
|
+
{
|
|
3623
|
+
type: "input",
|
|
3624
|
+
name: "title",
|
|
3625
|
+
message: "Issue title:",
|
|
3626
|
+
validate: (v) => v.trim().length > 0 || "Title cannot be empty"
|
|
3627
|
+
},
|
|
3628
|
+
{
|
|
3629
|
+
type: "editor",
|
|
3630
|
+
name: "body",
|
|
3631
|
+
message: "Issue body (markdown):",
|
|
3632
|
+
validate: (v) => v.trim().length > 0 || "Body cannot be empty"
|
|
3633
|
+
}
|
|
3634
|
+
]);
|
|
3635
|
+
const spinner = (await import("ora")).default("Creating issue...").start();
|
|
3636
|
+
try {
|
|
3637
|
+
const result = await client.mutation(api2.issues.create, {
|
|
3638
|
+
specId: spec._id,
|
|
3639
|
+
title: answers.title.trim(),
|
|
3640
|
+
body: answers.body.trim(),
|
|
3641
|
+
labels: []
|
|
3642
|
+
});
|
|
3643
|
+
spinner.succeed(
|
|
3644
|
+
chalk13.green(`Issue #${result.number} created on ${spec.scopedName}`)
|
|
3645
|
+
);
|
|
3646
|
+
} catch (err) {
|
|
3647
|
+
spinner.fail(chalk13.red(`Failed to create issue: ${err.message}`));
|
|
3648
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
async function handleIssuesView(specRef, issueNumber) {
|
|
3652
|
+
const api2 = await loadApi();
|
|
3653
|
+
const client = await getConvexClient();
|
|
3654
|
+
const spinner = (await import("ora")).default(`Loading issue #${issueNumber}...`).start();
|
|
3655
|
+
try {
|
|
3656
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
3657
|
+
const issue = await client.query(api2.issues.get, {
|
|
3658
|
+
specId: spec._id,
|
|
3659
|
+
number: issueNumber
|
|
3660
|
+
});
|
|
3661
|
+
if (!issue) {
|
|
3662
|
+
spinner.fail(chalk13.red(`Issue #${issueNumber} not found on ${spec.scopedName}`));
|
|
3663
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3664
|
+
}
|
|
3665
|
+
const commentsResult = await client.query(api2.comments.list, {
|
|
3666
|
+
targetType: "issue",
|
|
3667
|
+
targetId: issue._id,
|
|
3668
|
+
paginationOpts: { numItems: 10, cursor: null }
|
|
3669
|
+
});
|
|
3670
|
+
spinner.stop();
|
|
3671
|
+
const statusBadge = issue.status === "open" ? chalk13.green.bold(" OPEN ") : chalk13.gray.bold(" CLOSED ");
|
|
3672
|
+
console.log("");
|
|
3673
|
+
console.log(
|
|
3674
|
+
`${statusBadge} ${chalk13.bold(`#${issue.number}: ${issue.title}`)}`
|
|
3675
|
+
);
|
|
3676
|
+
console.log(chalk13.gray("\u2500".repeat(60)));
|
|
3677
|
+
console.log(
|
|
3678
|
+
`${chalk13.bold("Author:")} ${issue.author ? `@${issue.author.username}` : "unknown"} ${chalk13.bold("Created:")} ${new Date(issue.createdAt).toLocaleDateString()}`
|
|
3679
|
+
);
|
|
3680
|
+
if (issue.labels.length > 0) {
|
|
3681
|
+
console.log(`${chalk13.bold("Labels:")} ${issue.labels.join(", ")}`);
|
|
3682
|
+
}
|
|
3683
|
+
if (issue.closedAt) {
|
|
3684
|
+
console.log(
|
|
3685
|
+
`${chalk13.bold("Closed:")} ${new Date(issue.closedAt).toLocaleDateString()}`
|
|
3686
|
+
);
|
|
3687
|
+
}
|
|
3688
|
+
console.log("");
|
|
3689
|
+
console.log(issue.body);
|
|
3690
|
+
console.log("");
|
|
3691
|
+
if (commentsResult.page.length > 0) {
|
|
3692
|
+
console.log(
|
|
3693
|
+
chalk13.bold(`Comments (${issue.commentCount}):`)
|
|
3694
|
+
);
|
|
3695
|
+
console.log(chalk13.gray("\u2500".repeat(40)));
|
|
3696
|
+
for (const comment of commentsResult.page) {
|
|
3697
|
+
const author = comment.author ? `@${comment.author.username}` : "unknown";
|
|
3698
|
+
const edited = comment.editedAt ? chalk13.gray(" (edited)") : "";
|
|
3699
|
+
console.log(
|
|
3700
|
+
` ${chalk13.bold(author)} \u2014 ${relativeTime(comment.createdAt)}${edited}`
|
|
3701
|
+
);
|
|
3702
|
+
console.log(` ${comment.body}`);
|
|
3703
|
+
if (comment.replies && comment.replies.length > 0) {
|
|
3704
|
+
for (const reply of comment.replies) {
|
|
3705
|
+
const replyAuthor = reply.author ? `@${reply.author.username}` : "unknown";
|
|
3706
|
+
const replyEdited = reply.editedAt ? chalk13.gray(" (edited)") : "";
|
|
3707
|
+
console.log(
|
|
3708
|
+
` ${chalk13.bold(replyAuthor)} \u2014 ${relativeTime(reply.createdAt)}${replyEdited}`
|
|
3709
|
+
);
|
|
3710
|
+
console.log(` ${reply.body}`);
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
console.log("");
|
|
3714
|
+
}
|
|
3715
|
+
} else {
|
|
3716
|
+
console.log(chalk13.gray("No comments yet."));
|
|
3717
|
+
}
|
|
3718
|
+
} catch (err) {
|
|
3719
|
+
spinner.fail(
|
|
3720
|
+
chalk13.red(`Failed to load issue: ${err.message}`)
|
|
3721
|
+
);
|
|
3722
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
async function handleIssuesClose(specRef, issueNumber) {
|
|
3726
|
+
const creds = await requireAuth();
|
|
3727
|
+
const api2 = await loadApi();
|
|
3728
|
+
const client = await getConvexClient(creds.token);
|
|
3729
|
+
const spinner = (await import("ora")).default(`Closing issue #${issueNumber}...`).start();
|
|
3730
|
+
try {
|
|
3731
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
3732
|
+
const issue = await client.query(api2.issues.get, {
|
|
3733
|
+
specId: spec._id,
|
|
3734
|
+
number: issueNumber
|
|
3735
|
+
});
|
|
3736
|
+
if (!issue) {
|
|
3737
|
+
spinner.fail(chalk13.red(`Issue #${issueNumber} not found on ${spec.scopedName}`));
|
|
3738
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3739
|
+
}
|
|
3740
|
+
await client.mutation(api2.issues.close, {
|
|
3741
|
+
issueId: issue._id
|
|
3742
|
+
});
|
|
3743
|
+
spinner.succeed(
|
|
3744
|
+
chalk13.green(`Issue #${issueNumber} closed on ${spec.scopedName}`)
|
|
3745
|
+
);
|
|
3746
|
+
} catch (err) {
|
|
3747
|
+
spinner.fail(chalk13.red(`Failed to close issue: ${err.message}`));
|
|
3748
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
async function handleIssuesReopen(specRef, issueNumber) {
|
|
3752
|
+
const creds = await requireAuth();
|
|
3753
|
+
const api2 = await loadApi();
|
|
3754
|
+
const client = await getConvexClient(creds.token);
|
|
3755
|
+
const spinner = (await import("ora")).default(`Reopening issue #${issueNumber}...`).start();
|
|
3756
|
+
try {
|
|
3757
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
3758
|
+
const issue = await client.query(api2.issues.get, {
|
|
3759
|
+
specId: spec._id,
|
|
3760
|
+
number: issueNumber
|
|
3761
|
+
});
|
|
3762
|
+
if (!issue) {
|
|
3763
|
+
spinner.fail(chalk13.red(`Issue #${issueNumber} not found on ${spec.scopedName}`));
|
|
3764
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3765
|
+
}
|
|
3766
|
+
await client.mutation(api2.issues.reopen, {
|
|
3767
|
+
issueId: issue._id
|
|
3768
|
+
});
|
|
3769
|
+
spinner.succeed(
|
|
3770
|
+
chalk13.green(`Issue #${issueNumber} reopened on ${spec.scopedName}`)
|
|
3771
|
+
);
|
|
3772
|
+
} catch (err) {
|
|
3773
|
+
spinner.fail(
|
|
3774
|
+
chalk13.red(`Failed to reopen issue: ${err.message}`)
|
|
3775
|
+
);
|
|
3776
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
function createIssuesCommand() {
|
|
3780
|
+
return new Command13("issues").description("Manage issues on a spec").argument("<spec-id>", "Spec scoped name (@user/name) or document ID").argument("[action-or-number]", 'Issue number or "create"').argument("[action]", '"close" or "reopen" (with issue number)').option(
|
|
3781
|
+
"-s, --status <status>",
|
|
3782
|
+
"Filter by status: open, closed, all (default: open)"
|
|
3783
|
+
).option("--label <label>", "Filter by label").action(
|
|
3784
|
+
async (specId, actionOrNumber, action, opts) => {
|
|
3785
|
+
try {
|
|
3786
|
+
if (!actionOrNumber) {
|
|
3787
|
+
await handleIssuesList(specId, opts);
|
|
3788
|
+
} else if (actionOrNumber === "create") {
|
|
3789
|
+
await handleIssuesCreate(specId);
|
|
3790
|
+
} else {
|
|
3791
|
+
const issueNumber = parseInt(actionOrNumber, 10);
|
|
3792
|
+
if (isNaN(issueNumber) || issueNumber < 1) {
|
|
3793
|
+
console.error(
|
|
3794
|
+
chalk13.red(
|
|
3795
|
+
`Invalid issue number or action: "${actionOrNumber}". Use a number or "create".`
|
|
3796
|
+
)
|
|
3797
|
+
);
|
|
3798
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3799
|
+
}
|
|
3800
|
+
if (!action) {
|
|
3801
|
+
await handleIssuesView(specId, issueNumber);
|
|
3802
|
+
} else if (action === "close") {
|
|
3803
|
+
await handleIssuesClose(specId, issueNumber);
|
|
3804
|
+
} else if (action === "reopen") {
|
|
3805
|
+
await handleIssuesReopen(specId, issueNumber);
|
|
3806
|
+
} else {
|
|
3807
|
+
console.error(
|
|
3808
|
+
chalk13.red(
|
|
3809
|
+
`Unknown action: "${action}". Use "close" or "reopen".`
|
|
3810
|
+
)
|
|
3811
|
+
);
|
|
3812
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
} catch (err) {
|
|
3816
|
+
const error = err;
|
|
3817
|
+
if (error.code === String(EXIT_CODES.AUTH_ERROR)) {
|
|
3818
|
+
console.error(chalk13.red(error.message));
|
|
3819
|
+
process.exit(EXIT_CODES.AUTH_ERROR);
|
|
3820
|
+
}
|
|
3821
|
+
console.error(chalk13.red(`Error: ${error.message}`));
|
|
3822
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
);
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
// src/commands/comment.ts
|
|
3829
|
+
import { Command as Command14 } from "commander";
|
|
3830
|
+
import chalk14 from "chalk";
|
|
3831
|
+
async function handleComment(targetType, targetRef, body, opts) {
|
|
3832
|
+
const creds = await requireAuth();
|
|
3833
|
+
let api2;
|
|
3834
|
+
try {
|
|
3835
|
+
api2 = (await import("./api-GIDUNUXG.js")).api;
|
|
3836
|
+
} catch {
|
|
3837
|
+
console.error(
|
|
3838
|
+
chalk14.red("Error: Could not load Convex API bindings. Is CONVEX_URL configured?")
|
|
3839
|
+
);
|
|
3840
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3841
|
+
}
|
|
3842
|
+
const client = await getConvexClient(creds.token);
|
|
3843
|
+
const spinner = (await import("ora")).default("Posting comment...").start();
|
|
3844
|
+
try {
|
|
3845
|
+
let resolvedTargetType;
|
|
3846
|
+
let resolvedTargetId;
|
|
3847
|
+
if (targetType === "spec") {
|
|
3848
|
+
resolvedTargetType = "spec";
|
|
3849
|
+
const isScopedName = targetRef.startsWith("@") || targetRef.includes("/");
|
|
3850
|
+
const spec = await client.query(
|
|
3851
|
+
api2.specs.get,
|
|
3852
|
+
isScopedName ? { scopedName: targetRef } : { specId: targetRef }
|
|
3853
|
+
);
|
|
3854
|
+
if (!spec) {
|
|
3855
|
+
spinner.fail(chalk14.red(`Spec not found: ${targetRef}`));
|
|
3856
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3857
|
+
}
|
|
3858
|
+
resolvedTargetId = spec._id;
|
|
3859
|
+
} else if (targetType === "issue") {
|
|
3860
|
+
resolvedTargetType = "issue";
|
|
3861
|
+
const hashIndex = targetRef.lastIndexOf("#");
|
|
3862
|
+
if (hashIndex === -1) {
|
|
3863
|
+
spinner.fail(
|
|
3864
|
+
chalk14.red(
|
|
3865
|
+
"Invalid issue reference. Use format: @user/spec#<number>"
|
|
3866
|
+
)
|
|
3867
|
+
);
|
|
3868
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3869
|
+
}
|
|
3870
|
+
const specRef = targetRef.slice(0, hashIndex);
|
|
3871
|
+
const issueNumber = parseInt(targetRef.slice(hashIndex + 1), 10);
|
|
3872
|
+
if (isNaN(issueNumber) || issueNumber < 1) {
|
|
3873
|
+
spinner.fail(
|
|
3874
|
+
chalk14.red(`Invalid issue number in "${targetRef}". Use format: @user/spec#<number>`)
|
|
3875
|
+
);
|
|
3876
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3877
|
+
}
|
|
3878
|
+
const isScopedName = specRef.startsWith("@") || specRef.includes("/");
|
|
3879
|
+
const spec = await client.query(
|
|
3880
|
+
api2.specs.get,
|
|
3881
|
+
isScopedName ? { scopedName: specRef } : { specId: specRef }
|
|
3882
|
+
);
|
|
3883
|
+
if (!spec) {
|
|
3884
|
+
spinner.fail(chalk14.red(`Spec not found: ${specRef}`));
|
|
3885
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3886
|
+
}
|
|
3887
|
+
const issue = await client.query(api2.issues.get, {
|
|
3888
|
+
specId: spec._id,
|
|
3889
|
+
number: issueNumber
|
|
3890
|
+
});
|
|
3891
|
+
if (!issue) {
|
|
3892
|
+
spinner.fail(
|
|
3893
|
+
chalk14.red(`Issue #${issueNumber} not found on ${spec.scopedName}`)
|
|
3894
|
+
);
|
|
3895
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3896
|
+
}
|
|
3897
|
+
resolvedTargetId = issue._id;
|
|
3898
|
+
} else if (targetType === "bounty") {
|
|
3899
|
+
resolvedTargetType = "bounty";
|
|
3900
|
+
resolvedTargetId = targetRef;
|
|
3901
|
+
} else {
|
|
3902
|
+
spinner.fail(
|
|
3903
|
+
chalk14.red(
|
|
3904
|
+
`Invalid target type: "${targetType}". Use "spec", "issue", or "bounty".`
|
|
3905
|
+
)
|
|
3906
|
+
);
|
|
3907
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3908
|
+
}
|
|
3909
|
+
const args = {
|
|
3910
|
+
targetType: resolvedTargetType,
|
|
3911
|
+
targetId: resolvedTargetId,
|
|
3912
|
+
body: body.trim()
|
|
3913
|
+
};
|
|
3914
|
+
if (opts.reply) {
|
|
3915
|
+
args.parentId = opts.reply;
|
|
3916
|
+
}
|
|
3917
|
+
await client.mutation(api2.comments.create, args);
|
|
3918
|
+
spinner.succeed(chalk14.green(`Comment posted on ${targetType} ${targetRef}`));
|
|
3919
|
+
} catch (err) {
|
|
3920
|
+
spinner.fail(chalk14.red(`Failed to post comment: ${err.message}`));
|
|
3921
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
function createCommentCommand() {
|
|
3925
|
+
return new Command14("comment").description("Post a comment on a spec, issue, or bounty (requires login)").argument(
|
|
3926
|
+
"<target-type>",
|
|
3927
|
+
"Target type: spec, issue, or bounty"
|
|
3928
|
+
).argument(
|
|
3929
|
+
"<target-ref>",
|
|
3930
|
+
"Target reference (e.g., @user/spec, @user/spec#3, bounty-id)"
|
|
3931
|
+
).argument("<body>", "Comment body text").option(
|
|
3932
|
+
"--reply <comment-id>",
|
|
3933
|
+
"Reply to a specific comment (threading)"
|
|
3934
|
+
).action(
|
|
3935
|
+
async (targetType, targetRef, body, opts) => {
|
|
3936
|
+
try {
|
|
3937
|
+
await handleComment(targetType, targetRef, body, opts);
|
|
3938
|
+
} catch (err) {
|
|
3939
|
+
const error = err;
|
|
3940
|
+
if (error.code === String(EXIT_CODES.AUTH_ERROR)) {
|
|
3941
|
+
console.error(chalk14.red(error.message));
|
|
3942
|
+
process.exit(EXIT_CODES.AUTH_ERROR);
|
|
3943
|
+
}
|
|
3944
|
+
console.error(chalk14.red(`Error: ${error.message}`));
|
|
3945
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
);
|
|
3949
|
+
}
|
|
3950
|
+
|
|
2260
3951
|
// src/index.ts
|
|
2261
3952
|
import { createRequire as createRequire2 } from "module";
|
|
2262
3953
|
var _require2 = createRequire2(import.meta.url);
|
|
@@ -2277,6 +3968,8 @@ program.addCommand(createSearchCommand());
|
|
|
2277
3968
|
program.addCommand(createInfoCommand());
|
|
2278
3969
|
program.addCommand(createPublishCommand());
|
|
2279
3970
|
program.addCommand(createForkCommand());
|
|
3971
|
+
program.addCommand(createIssuesCommand());
|
|
3972
|
+
program.addCommand(createCommentCommand());
|
|
2280
3973
|
program.addCommand(createReportCommand());
|
|
2281
3974
|
program.addCommand(createConfigCommand());
|
|
2282
3975
|
program.parseAsync(process.argv).catch((err) => {
|