@specmarket/cli 0.0.5 → 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/dist/{chunk-DLEMNRTH.js → chunk-OTXWWFAO.js} +24 -2
- package/dist/chunk-OTXWWFAO.js.map +1 -0
- package/dist/{config-OAU6SJLC.js → config-5JMI3YAR.js} +2 -2
- package/dist/index.js +1283 -389
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/init.test.ts +162 -23
- package/src/commands/init.ts +349 -17
- package/src/commands/issues.test.ts +8 -3
- package/src/commands/issues.ts +2 -9
- package/src/commands/login.ts +2 -6
- package/src/commands/publish.test.ts +14 -1
- package/src/commands/publish.ts +1 -0
- package/src/commands/run.test.ts +206 -0
- package/src/commands/run.ts +63 -3
- package/src/commands/validate.test.ts +83 -6
- package/src/commands/validate.ts +96 -114
- package/src/lib/format-detection.test.ts +4 -4
- package/src/lib/format-detection.ts +3 -3
- 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 +475 -98
- package/src/lib/telemetry.ts +5 -0
- package/dist/chunk-DLEMNRTH.js.map +0 -1
- /package/dist/{config-OAU6SJLC.js.map → config-5JMI3YAR.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
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,7 +17,7 @@ import {
|
|
|
13
17
|
specYamlSchema,
|
|
14
18
|
specmarketSidecarSchema,
|
|
15
19
|
transformInfrastructure
|
|
16
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-OTXWWFAO.js";
|
|
17
21
|
import {
|
|
18
22
|
api
|
|
19
23
|
} from "./chunk-JEUDDJP7.js";
|
|
@@ -169,9 +173,7 @@ async function handleTokenLogin(token) {
|
|
|
169
173
|
}
|
|
170
174
|
}
|
|
171
175
|
async function handleDeviceCodeLogin() {
|
|
172
|
-
const
|
|
173
|
-
const baseUrl = config.convexUrl ?? process.env["CONVEX_URL"] ?? "https://your-deployment.convex.cloud";
|
|
174
|
-
const webUrl = baseUrl.replace("convex.cloud", "specmarket.dev");
|
|
176
|
+
const webUrl = DEFAULT_WEB_URL;
|
|
175
177
|
const client = await getConvexClient();
|
|
176
178
|
let api2;
|
|
177
179
|
try {
|
|
@@ -346,10 +348,177 @@ function createWhoamiCommand() {
|
|
|
346
348
|
import { Command as Command4 } from "commander";
|
|
347
349
|
import chalk4 from "chalk";
|
|
348
350
|
import ora2 from "ora";
|
|
349
|
-
import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
|
|
350
|
-
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";
|
|
351
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
|
|
352
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
|
+
});
|
|
353
522
|
var SPEC_YAML_TEMPLATE = (data) => `# SpecMarket Spec Configuration
|
|
354
523
|
# See: https://specmarket.dev/docs/spec-yaml
|
|
355
524
|
|
|
@@ -492,26 +661,26 @@ var TASKS_MD_TEMPLATE = (displayName) => `# Tasks
|
|
|
492
661
|
|
|
493
662
|
## Discovered Issues
|
|
494
663
|
`;
|
|
495
|
-
async function
|
|
664
|
+
async function promptMetadataOnly(defaultDisplayName) {
|
|
496
665
|
const { default: inquirer } = await import("inquirer");
|
|
497
666
|
const answers = await inquirer.prompt([
|
|
498
667
|
{
|
|
499
668
|
type: "input",
|
|
500
|
-
name: "
|
|
501
|
-
message: "
|
|
502
|
-
default:
|
|
503
|
-
validate: (v) => /^[a-z0-9-]+$/.test(v) || "Must be lowercase alphanumeric with hyphens"
|
|
669
|
+
name: "displayName",
|
|
670
|
+
message: "Display name for the marketplace:",
|
|
671
|
+
default: defaultDisplayName ?? "My Spec"
|
|
504
672
|
},
|
|
505
673
|
{
|
|
506
674
|
type: "input",
|
|
507
|
-
name: "
|
|
508
|
-
message: "
|
|
509
|
-
default:
|
|
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"
|
|
510
679
|
},
|
|
511
680
|
{
|
|
512
681
|
type: "input",
|
|
513
682
|
name: "replacesSaas",
|
|
514
|
-
message: "What SaaS product does this replace? (optional,
|
|
683
|
+
message: "What SaaS product does this replace? (optional, Enter to skip):",
|
|
515
684
|
default: ""
|
|
516
685
|
},
|
|
517
686
|
{
|
|
@@ -540,204 +709,298 @@ async function handleInit(opts) {
|
|
|
540
709
|
]
|
|
541
710
|
}
|
|
542
711
|
]);
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
displayName: answers.displayName,
|
|
551
|
-
replacesSaas: answers.replacesSaas || void 0,
|
|
552
|
-
outputType: answers.outputType,
|
|
553
|
-
primaryStack: answers.primaryStack
|
|
554
|
-
};
|
|
555
|
-
await Promise.all([
|
|
556
|
-
writeFile2(join2(targetDir, "spec.yaml"), SPEC_YAML_TEMPLATE(data)),
|
|
557
|
-
writeFile2(join2(targetDir, "PROMPT.md"), PROMPT_MD_TEMPLATE(data)),
|
|
558
|
-
writeFile2(join2(targetDir, "SPEC.md"), SPEC_MD_TEMPLATE(data)),
|
|
559
|
-
writeFile2(join2(targetDir, "SUCCESS_CRITERIA.md"), SUCCESS_CRITERIA_TEMPLATE),
|
|
560
|
-
writeFile2(join2(targetDir, "stdlib", "STACK.md"), STACK_MD_TEMPLATE(answers.primaryStack)),
|
|
561
|
-
writeFile2(join2(targetDir, "TASKS.md"), TASKS_MD_TEMPLATE(answers.displayName))
|
|
562
|
-
]);
|
|
563
|
-
spinner.succeed(chalk4.green(`Spec created at ${targetDir}`));
|
|
564
|
-
console.log("");
|
|
565
|
-
console.log(chalk4.bold("Next steps:"));
|
|
566
|
-
console.log(` 1. ${chalk4.cyan(`cd ${answers.name}`)}`);
|
|
567
|
-
console.log(` 2. Edit ${chalk4.cyan("SPEC.md")} with your application requirements`);
|
|
568
|
-
console.log(` 3. Edit ${chalk4.cyan("SUCCESS_CRITERIA.md")} with specific pass/fail criteria`);
|
|
569
|
-
console.log(` 4. Run ${chalk4.cyan(`specmarket validate`)} to check your spec`);
|
|
570
|
-
console.log(` 5. Run ${chalk4.cyan(`specmarket run`)} to execute the spec`);
|
|
571
|
-
} catch (err) {
|
|
572
|
-
spinner.fail(chalk4.red(`Failed to create spec: ${err.message}`));
|
|
573
|
-
throw err;
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
function createInitCommand() {
|
|
577
|
-
return new Command4("init").description("Create a new spec directory with template files").option("-n, --name <name>", "Spec name (skip prompt)").option("-p, --path <path>", "Target directory path (defaults to spec name)").action(async (opts) => {
|
|
578
|
-
try {
|
|
579
|
-
await handleInit(opts);
|
|
580
|
-
} catch (err) {
|
|
581
|
-
console.error(chalk4.red(`Init failed: ${err.message}`));
|
|
582
|
-
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
|
583
|
-
}
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// src/commands/validate.ts
|
|
588
|
-
import { Command as Command5 } from "commander";
|
|
589
|
-
import chalk5 from "chalk";
|
|
590
|
-
import { readFile as readFile3, readdir as readdir2, access as access2 } from "fs/promises";
|
|
591
|
-
import { join as join4, resolve as resolve2, relative, normalize } from "path";
|
|
592
|
-
import { parse as parseYaml2 } from "yaml";
|
|
593
|
-
|
|
594
|
-
// src/lib/format-detection.ts
|
|
595
|
-
import { readFile as readFile2, readdir, access } from "fs/promises";
|
|
596
|
-
import { join as join3 } from "path";
|
|
597
|
-
import { parse as parseYaml } from "yaml";
|
|
598
|
-
async function fileExists(filePath) {
|
|
599
|
-
try {
|
|
600
|
-
await access(filePath);
|
|
601
|
-
return true;
|
|
602
|
-
} catch {
|
|
603
|
-
return false;
|
|
604
|
-
}
|
|
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
|
+
};
|
|
605
719
|
}
|
|
606
|
-
async function
|
|
720
|
+
async function dirHasFiles(dir) {
|
|
607
721
|
try {
|
|
608
|
-
await
|
|
609
|
-
return
|
|
722
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
723
|
+
return entries.some((e) => e.isFile());
|
|
610
724
|
} catch {
|
|
611
725
|
return false;
|
|
612
726
|
}
|
|
613
727
|
}
|
|
614
|
-
async function
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
)
|
|
620
|
-
|
|
621
|
-
|
|
728
|
+
async function handleInit(opts) {
|
|
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;
|
|
622
765
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
632
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;
|
|
633
825
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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;
|
|
637
871
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
872
|
+
const answers = await inquirer.prompt([
|
|
873
|
+
{
|
|
874
|
+
type: "input",
|
|
875
|
+
name: "name",
|
|
876
|
+
message: "Spec name (lowercase, hyphens only):",
|
|
877
|
+
default: opts.name ?? "my-spec",
|
|
878
|
+
validate: (v) => /^[a-z0-9-]+$/.test(v) || "Must be lowercase alphanumeric with hyphens"
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
type: "input",
|
|
882
|
+
name: "displayName",
|
|
883
|
+
message: "Display name:",
|
|
884
|
+
default: (ans) => ans.name.split("-").map((w) => w[0]?.toUpperCase() + w.slice(1)).join(" ")
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
type: "input",
|
|
888
|
+
name: "replacesSaas",
|
|
889
|
+
message: "What SaaS product does this replace? (optional, press Enter to skip):",
|
|
890
|
+
default: ""
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
type: "list",
|
|
894
|
+
name: "outputType",
|
|
895
|
+
message: "Output type:",
|
|
896
|
+
choices: [
|
|
897
|
+
{ name: "Web Application", value: "web-app" },
|
|
898
|
+
{ name: "CLI Tool", value: "cli-tool" },
|
|
899
|
+
{ name: "API Service", value: "api-service" },
|
|
900
|
+
{ name: "Library/Package", value: "library" },
|
|
901
|
+
{ name: "Mobile App", value: "mobile-app" }
|
|
902
|
+
]
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
type: "list",
|
|
906
|
+
name: "primaryStack",
|
|
907
|
+
message: "Primary stack:",
|
|
908
|
+
choices: [
|
|
909
|
+
{ name: "Next.js + TypeScript", value: "nextjs-typescript" },
|
|
910
|
+
{ name: "Astro + TypeScript", value: "astro-typescript" },
|
|
911
|
+
{ name: "Python + FastAPI", value: "python-fastapi" },
|
|
912
|
+
{ name: "Go", value: "go" },
|
|
913
|
+
{ name: "Rust", value: "rust" },
|
|
914
|
+
{ name: "Other", value: "other" }
|
|
915
|
+
]
|
|
916
|
+
}
|
|
917
|
+
]);
|
|
918
|
+
const targetDir = resolve(answers.name);
|
|
919
|
+
const spinner = ora2(`Creating spec directory at ${targetDir}...`).start();
|
|
920
|
+
try {
|
|
921
|
+
await mkdir2(targetDir, { recursive: true });
|
|
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;
|
|
648
928
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
};
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
};
|
|
679
|
-
}
|
|
680
|
-
const hasSpecMd = await fileExists(join3(dir, "spec.md"));
|
|
681
|
-
const hasPlanMd = await fileExists(join3(dir, "plan.md"));
|
|
682
|
-
const hasTasksMd = await fileExists(join3(dir, "tasks.md"));
|
|
683
|
-
if (hasSpecMd && (hasPlanMd || hasTasksMd)) {
|
|
684
|
-
return {
|
|
685
|
-
format: "speckit",
|
|
686
|
-
detectedBy: "heuristic",
|
|
687
|
-
confidence: "high"
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
if (hasSpecMd) {
|
|
691
|
-
return {
|
|
692
|
-
format: "speckit",
|
|
693
|
-
detectedBy: "heuristic",
|
|
694
|
-
confidence: "high"
|
|
695
|
-
};
|
|
696
|
-
}
|
|
697
|
-
const hasPrdMd = await fileExists(join3(dir, "prd.md"));
|
|
698
|
-
const hasArchitectureMd = await fileExists(join3(dir, "architecture.md"));
|
|
699
|
-
const storyFiles = await hasStoryFiles(dir);
|
|
700
|
-
if (hasPrdMd && (hasArchitectureMd || storyFiles)) {
|
|
701
|
-
return {
|
|
702
|
-
format: "bmad",
|
|
703
|
-
detectedBy: "heuristic",
|
|
704
|
-
confidence: "high"
|
|
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 });
|
|
952
|
+
const data = {
|
|
953
|
+
name: answers.name,
|
|
954
|
+
displayName: answers.displayName,
|
|
955
|
+
replacesSaas: answers.replacesSaas || void 0,
|
|
956
|
+
outputType: answers.outputType,
|
|
957
|
+
primaryStack: answers.primaryStack
|
|
705
958
|
};
|
|
959
|
+
await Promise.all([
|
|
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))
|
|
967
|
+
]);
|
|
968
|
+
spinner.succeed(chalk4.green(`Spec created at ${targetDir}`));
|
|
969
|
+
console.log("");
|
|
970
|
+
console.log(chalk4.bold("Next steps:"));
|
|
971
|
+
console.log(` 1. ${chalk4.cyan(`cd ${answers.name}`)}`);
|
|
972
|
+
console.log(` 2. Edit ${chalk4.cyan("SPEC.md")} with your application requirements`);
|
|
973
|
+
console.log(` 3. Edit ${chalk4.cyan("SUCCESS_CRITERIA.md")} with specific pass/fail criteria`);
|
|
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`);
|
|
976
|
+
} catch (err) {
|
|
977
|
+
spinner.fail(chalk4.red(`Failed to create spec: ${err.message}`));
|
|
978
|
+
throw err;
|
|
706
979
|
}
|
|
707
|
-
|
|
708
|
-
|
|
980
|
+
}
|
|
981
|
+
function createInitCommand() {
|
|
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) => {
|
|
709
985
|
try {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
format: "ralph",
|
|
715
|
-
detectedBy: "heuristic",
|
|
716
|
-
confidence: "high"
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
} catch {
|
|
986
|
+
await handleInit(opts);
|
|
987
|
+
} catch (err) {
|
|
988
|
+
console.error(chalk4.red(`Init failed: ${err.message}`));
|
|
989
|
+
process.exit(EXIT_CODES.RUNTIME_ERROR);
|
|
720
990
|
}
|
|
721
|
-
}
|
|
722
|
-
if (await hasMarkdownFiles(dir)) {
|
|
723
|
-
return {
|
|
724
|
-
format: "custom",
|
|
725
|
-
detectedBy: "heuristic",
|
|
726
|
-
confidence: "low"
|
|
727
|
-
};
|
|
728
|
-
}
|
|
729
|
-
return {
|
|
730
|
-
format: "custom",
|
|
731
|
-
detectedBy: "heuristic",
|
|
732
|
-
confidence: "low"
|
|
733
|
-
};
|
|
991
|
+
});
|
|
734
992
|
}
|
|
735
993
|
|
|
736
994
|
// src/commands/validate.ts
|
|
995
|
+
import { Command as Command5 } from "commander";
|
|
996
|
+
import chalk5 from "chalk";
|
|
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";
|
|
737
1000
|
async function collectFiles(currentDir, baseDir, extensions) {
|
|
738
1001
|
const results = [];
|
|
739
1002
|
try {
|
|
740
|
-
const entries = await
|
|
1003
|
+
const entries = await readdir3(currentDir, { withFileTypes: true });
|
|
741
1004
|
for (const entry of entries) {
|
|
742
1005
|
const fullPath = join4(currentDir, entry.name);
|
|
743
1006
|
if (entry.isDirectory()) {
|
|
@@ -819,7 +1082,7 @@ async function detectCircularReferences(dir) {
|
|
|
819
1082
|
}
|
|
820
1083
|
return cycles;
|
|
821
1084
|
}
|
|
822
|
-
async function
|
|
1085
|
+
async function validateSpecmarketContent(dir, errors, warnings) {
|
|
823
1086
|
for (const file of REQUIRED_SPEC_FILES) {
|
|
824
1087
|
const filePath = join4(dir, file);
|
|
825
1088
|
try {
|
|
@@ -926,9 +1189,10 @@ async function validateSpec(specPath) {
|
|
|
926
1189
|
const dir = resolve2(specPath);
|
|
927
1190
|
const errors = [];
|
|
928
1191
|
const warnings = [];
|
|
929
|
-
|
|
1192
|
+
let format;
|
|
1193
|
+
let formatDetectedBy = "sidecar";
|
|
930
1194
|
try {
|
|
931
|
-
const entries = await
|
|
1195
|
+
const entries = await readdir3(dir, { withFileTypes: true });
|
|
932
1196
|
const hasAnyFile = entries.some((e) => e.isFile());
|
|
933
1197
|
if (!hasAnyFile) {
|
|
934
1198
|
errors.push("Directory is empty or has no readable files");
|
|
@@ -937,7 +1201,10 @@ async function validateSpec(specPath) {
|
|
|
937
1201
|
errors.push("Directory is empty or unreadable");
|
|
938
1202
|
}
|
|
939
1203
|
const sidecarPath = join4(dir, SIDECAR_FILENAME);
|
|
940
|
-
|
|
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 {
|
|
941
1208
|
try {
|
|
942
1209
|
const raw = await readFile3(sidecarPath, "utf-8");
|
|
943
1210
|
const parsed = parseYaml2(raw);
|
|
@@ -950,28 +1217,92 @@ async function validateSpec(specPath) {
|
|
|
950
1217
|
}
|
|
951
1218
|
} else {
|
|
952
1219
|
const sidecar = sidecarResult.data;
|
|
1220
|
+
format = sidecar.spec_format;
|
|
953
1221
|
if (sidecar.estimated_tokens !== void 0) {
|
|
954
1222
|
if (sidecar.estimated_tokens < 1e3) {
|
|
955
1223
|
warnings.push(
|
|
956
|
-
`
|
|
1224
|
+
`estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
|
|
957
1225
|
);
|
|
958
1226
|
}
|
|
959
1227
|
if (sidecar.estimated_tokens > 1e7) {
|
|
960
|
-
warnings.push(
|
|
961
|
-
`sidecar estimated_tokens (${sidecar.estimated_tokens}) seems very high.`
|
|
962
|
-
);
|
|
1228
|
+
warnings.push(`estimated_tokens (${sidecar.estimated_tokens}) seems very high.`);
|
|
963
1229
|
}
|
|
964
1230
|
}
|
|
965
1231
|
if (sidecar.estimated_cost_usd !== void 0 && sidecar.estimated_cost_usd < 0.01) {
|
|
966
1232
|
warnings.push(
|
|
967
|
-
`
|
|
1233
|
+
`estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
|
|
968
1234
|
);
|
|
969
1235
|
}
|
|
970
1236
|
if (sidecar.estimated_time_minutes !== void 0 && sidecar.estimated_time_minutes < 1) {
|
|
971
1237
|
warnings.push(
|
|
972
|
-
`
|
|
1238
|
+
`estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
|
|
973
1239
|
);
|
|
974
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
|
+
}
|
|
975
1306
|
}
|
|
976
1307
|
} catch (err) {
|
|
977
1308
|
errors.push(
|
|
@@ -979,90 +1310,12 @@ async function validateSpec(specPath) {
|
|
|
979
1310
|
);
|
|
980
1311
|
}
|
|
981
1312
|
}
|
|
982
|
-
const hasSpecYaml = await fileExists(join4(dir, "spec.yaml"));
|
|
983
|
-
if (hasSpecYaml || detection.format === "specmarket-legacy") {
|
|
984
|
-
await validateLegacySpec(dir, errors, warnings);
|
|
985
|
-
}
|
|
986
|
-
switch (detection.format) {
|
|
987
|
-
case "specmarket-legacy":
|
|
988
|
-
break;
|
|
989
|
-
case "speckit": {
|
|
990
|
-
const hasSpecMd = await fileExists(join4(dir, "spec.md"));
|
|
991
|
-
const hasTasksMd = await fileExists(join4(dir, "tasks.md"));
|
|
992
|
-
const hasPlanMd = await fileExists(join4(dir, "plan.md"));
|
|
993
|
-
const hasSpecifyDir = await directoryExists(join4(dir, ".specify"));
|
|
994
|
-
if (!hasSpecMd) {
|
|
995
|
-
errors.push("speckit format requires spec.md");
|
|
996
|
-
}
|
|
997
|
-
if (!hasTasksMd && !hasPlanMd) {
|
|
998
|
-
errors.push("speckit format requires tasks.md or plan.md");
|
|
999
|
-
}
|
|
1000
|
-
if (!hasSpecifyDir) {
|
|
1001
|
-
warnings.push("speckit format: .specify/ directory is recommended");
|
|
1002
|
-
}
|
|
1003
|
-
break;
|
|
1004
|
-
}
|
|
1005
|
-
case "bmad": {
|
|
1006
|
-
const hasPrdMd = await fileExists(join4(dir, "prd.md"));
|
|
1007
|
-
const hasStory = await hasStoryFiles(dir);
|
|
1008
|
-
if (!hasPrdMd && !hasStory) {
|
|
1009
|
-
errors.push("bmad format requires prd.md or story-*.md files");
|
|
1010
|
-
}
|
|
1011
|
-
const hasArch = await fileExists(join4(dir, "architecture.md"));
|
|
1012
|
-
if (!hasArch) {
|
|
1013
|
-
warnings.push("bmad format: architecture.md is recommended");
|
|
1014
|
-
}
|
|
1015
|
-
break;
|
|
1016
|
-
}
|
|
1017
|
-
case "ralph": {
|
|
1018
|
-
const prdPath = join4(dir, "prd.json");
|
|
1019
|
-
if (!await fileExists(prdPath)) {
|
|
1020
|
-
errors.push("ralph format requires prd.json");
|
|
1021
|
-
break;
|
|
1022
|
-
}
|
|
1023
|
-
try {
|
|
1024
|
-
const raw = await readFile3(prdPath, "utf-8");
|
|
1025
|
-
const data = JSON.parse(raw);
|
|
1026
|
-
if (!data || typeof data !== "object" || !("userStories" in data) || !Array.isArray(data.userStories)) {
|
|
1027
|
-
errors.push("ralph format: prd.json must have userStories array");
|
|
1028
|
-
}
|
|
1029
|
-
} catch {
|
|
1030
|
-
errors.push("ralph format: prd.json must be valid JSON with userStories array");
|
|
1031
|
-
}
|
|
1032
|
-
break;
|
|
1033
|
-
}
|
|
1034
|
-
case "custom":
|
|
1035
|
-
default: {
|
|
1036
|
-
const hasMd = await hasMarkdownFiles(dir);
|
|
1037
|
-
if (!hasMd) {
|
|
1038
|
-
errors.push("custom format requires at least one .md file");
|
|
1039
|
-
break;
|
|
1040
|
-
}
|
|
1041
|
-
const textExtensions = /* @__PURE__ */ new Set([".md"]);
|
|
1042
|
-
const mdFiles = await collectFiles(dir, dir, textExtensions);
|
|
1043
|
-
let hasSubstantialMd = false;
|
|
1044
|
-
for (const f of mdFiles) {
|
|
1045
|
-
try {
|
|
1046
|
-
const content = await readFile3(join4(dir, f), "utf-8");
|
|
1047
|
-
if (content.length > 100) {
|
|
1048
|
-
hasSubstantialMd = true;
|
|
1049
|
-
break;
|
|
1050
|
-
}
|
|
1051
|
-
} catch {
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
if (!hasSubstantialMd) {
|
|
1055
|
-
errors.push("custom format requires at least one .md file larger than 100 bytes");
|
|
1056
|
-
}
|
|
1057
|
-
break;
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
1313
|
return {
|
|
1061
1314
|
valid: errors.length === 0,
|
|
1062
1315
|
errors,
|
|
1063
1316
|
warnings,
|
|
1064
|
-
format
|
|
1065
|
-
formatDetectedBy
|
|
1317
|
+
format,
|
|
1318
|
+
formatDetectedBy
|
|
1066
1319
|
};
|
|
1067
1320
|
}
|
|
1068
1321
|
function createValidateCommand() {
|
|
@@ -1070,7 +1323,7 @@ function createValidateCommand() {
|
|
|
1070
1323
|
try {
|
|
1071
1324
|
const result = await validateSpec(specPath);
|
|
1072
1325
|
if (result.format !== void 0) {
|
|
1073
|
-
console.log(chalk5.gray(`
|
|
1326
|
+
console.log(chalk5.gray(`Format: ${result.format}`));
|
|
1074
1327
|
}
|
|
1075
1328
|
if (result.warnings.length > 0) {
|
|
1076
1329
|
console.log(chalk5.yellow("\nWarnings:"));
|
|
@@ -1106,9 +1359,9 @@ Validation failed with ${result.errors.length} error(s).`)
|
|
|
1106
1359
|
import { Command as Command6 } from "commander";
|
|
1107
1360
|
import chalk6 from "chalk";
|
|
1108
1361
|
import ora3 from "ora";
|
|
1109
|
-
import { readFile as
|
|
1110
|
-
import { join as
|
|
1111
|
-
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";
|
|
1112
1365
|
|
|
1113
1366
|
// src/lib/telemetry.ts
|
|
1114
1367
|
import createDebug5 from "debug";
|
|
@@ -1139,6 +1392,11 @@ async function submitTelemetry(report, opts = {}) {
|
|
|
1139
1392
|
specVersion: report.specVersion,
|
|
1140
1393
|
model: report.model,
|
|
1141
1394
|
runner: report.runner,
|
|
1395
|
+
harness: report.harness,
|
|
1396
|
+
specFormat: report.specFormat,
|
|
1397
|
+
environmentType: report.environmentType,
|
|
1398
|
+
steeringActionCount: report.steeringActionCount,
|
|
1399
|
+
isPureRun: report.isPureRun,
|
|
1142
1400
|
loopCount: report.loopCount,
|
|
1143
1401
|
totalTokens: report.totalTokens,
|
|
1144
1402
|
totalCostUsd: report.totalCostUsd,
|
|
@@ -1171,45 +1429,474 @@ async function promptTelemetryOptIn() {
|
|
|
1171
1429
|
default: false
|
|
1172
1430
|
}
|
|
1173
1431
|
]);
|
|
1174
|
-
const { saveConfig: saveConfig2 } = await import("./config-
|
|
1432
|
+
const { saveConfig: saveConfig2 } = await import("./config-5JMI3YAR.js");
|
|
1175
1433
|
await saveConfig2({ ...config, telemetry: optIn, telemetryPrompted: true });
|
|
1176
1434
|
return optIn;
|
|
1177
1435
|
}
|
|
1178
1436
|
|
|
1179
1437
|
// src/lib/ralph-loop.ts
|
|
1180
1438
|
import { spawn } from "child_process";
|
|
1181
|
-
import { mkdir as mkdir3, writeFile as writeFile3, readFile as
|
|
1182
|
-
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";
|
|
1183
1441
|
import { homedir as homedir2 } from "os";
|
|
1184
1442
|
import { randomUUID } from "crypto";
|
|
1185
1443
|
import { exec } from "child_process";
|
|
1186
1444
|
import { promisify } from "util";
|
|
1187
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
|
|
1188
1860
|
var debug6 = createDebug6("specmarket:runner");
|
|
1189
1861
|
var execAsync = promisify(exec);
|
|
1190
|
-
async function checkClaudeCliInstalled() {
|
|
1862
|
+
async function checkClaudeCliInstalled(harness) {
|
|
1863
|
+
const h = harness ?? DEFAULT_HARNESS;
|
|
1864
|
+
const binaryName = HARNESS_BINARY[h] ?? "claude";
|
|
1191
1865
|
try {
|
|
1192
|
-
await execAsync(
|
|
1866
|
+
await execAsync(`which ${binaryName}`);
|
|
1193
1867
|
} catch {
|
|
1868
|
+
const installHint = HARNESS_INSTALL_HINT[h] ?? `Install ${binaryName} and ensure it is in your PATH.`;
|
|
1194
1869
|
throw new Error(
|
|
1195
|
-
`
|
|
1870
|
+
`Harness "${h}" binary "${binaryName}" is not installed or not in your PATH.
|
|
1196
1871
|
|
|
1197
|
-
|
|
1198
|
-
npm install -g @anthropic-ai/claude-code
|
|
1199
|
-
|
|
1200
|
-
Or visit: https://www.anthropic.com/claude-code
|
|
1872
|
+
${installHint}
|
|
1201
1873
|
`
|
|
1202
1874
|
);
|
|
1203
1875
|
}
|
|
1204
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
|
+
};
|
|
1205
1887
|
async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
1206
1888
|
const maxLoops = opts.maxLoops ?? RUN_DEFAULTS.MAX_LOOPS;
|
|
1207
1889
|
const budgetTokens = opts.maxBudgetUsd ? opts.maxBudgetUsd / specYaml.estimatedCostUsd * specYaml.estimatedTokens : specYaml.estimatedTokens * RUN_DEFAULTS.BUDGET_MULTIPLIER;
|
|
1890
|
+
const harness = opts.harness ?? DEFAULT_HARNESS;
|
|
1208
1891
|
const runId = opts.resumeRunId ?? randomUUID();
|
|
1209
|
-
const runsBaseDir =
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1212
|
-
|
|
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);
|
|
1213
1900
|
if (opts.dryRun) {
|
|
1214
1901
|
debug6("Dry run mode \u2014 skipping execution");
|
|
1215
1902
|
const report2 = {
|
|
@@ -1217,6 +1904,11 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
1217
1904
|
specVersion: specYaml.version,
|
|
1218
1905
|
model: opts.model ?? specYaml.minModel,
|
|
1219
1906
|
runner: specYaml.runner,
|
|
1907
|
+
harness,
|
|
1908
|
+
specFormat: opts.specFormat,
|
|
1909
|
+
environmentType,
|
|
1910
|
+
steeringActionCount: 0,
|
|
1911
|
+
isPureRun: false,
|
|
1220
1912
|
loopCount: 0,
|
|
1221
1913
|
totalTokens: 0,
|
|
1222
1914
|
totalCostUsd: 0,
|
|
@@ -1239,8 +1931,13 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
1239
1931
|
totalTokens = existingReport.totalTokens;
|
|
1240
1932
|
debug6("Resuming from iteration %d with %d tokens carried over", startIteration, totalTokens);
|
|
1241
1933
|
}
|
|
1934
|
+
await ensureMetaInstructions(specDir, runDir, opts.specFormat);
|
|
1935
|
+
} else if (usingWorkdir) {
|
|
1936
|
+
await ensureMetaInstructions(specDir, runDir, opts.specFormat);
|
|
1937
|
+
await initGit(runDir);
|
|
1242
1938
|
} else {
|
|
1243
1939
|
await copySpecFiles(specDir, runDir);
|
|
1940
|
+
await ensureMetaInstructions(specDir, runDir, opts.specFormat);
|
|
1244
1941
|
await initGit(runDir);
|
|
1245
1942
|
}
|
|
1246
1943
|
const startTime = Date.now();
|
|
@@ -1248,14 +1945,24 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
1248
1945
|
let consecutiveNoChange = 0;
|
|
1249
1946
|
let lastOutput = "";
|
|
1250
1947
|
let consecutiveSameOutput = 0;
|
|
1948
|
+
const steeringLog = [];
|
|
1949
|
+
let steeringActionCount = 0;
|
|
1950
|
+
let testPhaseAttempts = 0;
|
|
1251
1951
|
let finalStatus = "failure";
|
|
1252
1952
|
let successCriteriaResults = [];
|
|
1253
1953
|
for (let i = startIteration; i <= maxLoops; i++) {
|
|
1254
1954
|
debug6("Starting loop iteration %d/%d", i, maxLoops);
|
|
1255
1955
|
const iterStart = Date.now();
|
|
1256
|
-
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);
|
|
1257
1963
|
const iterDuration = Date.now() - iterStart;
|
|
1258
|
-
const
|
|
1964
|
+
const activeModel = opts.model ?? specYaml.minModel;
|
|
1965
|
+
const tokensThisLoop = parseTokensFromOutput(result.stdout, activeModel);
|
|
1259
1966
|
totalTokens += tokensThisLoop;
|
|
1260
1967
|
const gitDiff = await getGitDiff(runDir);
|
|
1261
1968
|
const iteration = {
|
|
@@ -1269,7 +1976,7 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
1269
1976
|
iterations.push(iteration);
|
|
1270
1977
|
onProgress?.(iteration);
|
|
1271
1978
|
await writeFile3(
|
|
1272
|
-
|
|
1979
|
+
join6(runDir, `iteration-${i}.json`),
|
|
1273
1980
|
JSON.stringify(iteration, null, 2)
|
|
1274
1981
|
);
|
|
1275
1982
|
await stageAllChanges(runDir);
|
|
@@ -1301,26 +2008,70 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
1301
2008
|
consecutiveSameOutput = 0;
|
|
1302
2009
|
lastOutput = currentOutputHash;
|
|
1303
2010
|
}
|
|
1304
|
-
const
|
|
1305
|
-
if (
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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(() => []);
|
|
1310
2048
|
}
|
|
1311
|
-
successCriteriaResults = completionCheck.results;
|
|
1312
2049
|
}
|
|
1313
2050
|
if (finalStatus === "failure" && successCriteriaResults.length === 0) {
|
|
1314
2051
|
successCriteriaResults = await evaluateSuccessCriteria(runDir).catch(() => []);
|
|
1315
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
|
+
}
|
|
1316
2061
|
const totalTimeMinutes = (Date.now() - startTime) / 6e4;
|
|
1317
2062
|
const costPerToken = specYaml.estimatedCostUsd / specYaml.estimatedTokens;
|
|
1318
2063
|
const totalCostUsd = totalTokens * costPerToken;
|
|
2064
|
+
const detectedSpecFormat = opts.specFormat ?? (await detectSpecFormat(runDir)).format;
|
|
1319
2065
|
const report = {
|
|
1320
2066
|
runId,
|
|
1321
2067
|
specVersion: specYaml.version,
|
|
1322
2068
|
model: opts.model ?? specYaml.minModel,
|
|
1323
2069
|
runner: specYaml.runner,
|
|
2070
|
+
harness,
|
|
2071
|
+
specFormat: detectedSpecFormat,
|
|
2072
|
+
environmentType,
|
|
2073
|
+
steeringActionCount,
|
|
2074
|
+
isPureRun: finalStatus === "success" && steeringActionCount === 0,
|
|
1324
2075
|
loopCount: iterations.length,
|
|
1325
2076
|
totalTokens,
|
|
1326
2077
|
totalCostUsd,
|
|
@@ -1332,15 +2083,45 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
1332
2083
|
cliVersion: opts.cliVersion
|
|
1333
2084
|
};
|
|
1334
2085
|
await writeFile3(
|
|
1335
|
-
|
|
2086
|
+
join6(runDir, "run-report.json"),
|
|
1336
2087
|
JSON.stringify(report, null, 2)
|
|
1337
2088
|
);
|
|
1338
2089
|
debug6("Run complete: %s (status=%s, loops=%d)", runId, finalStatus, iterations.length);
|
|
1339
2090
|
return { report, outputDir: runDir };
|
|
1340
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
|
+
}
|
|
1341
2122
|
async function copySpecFiles(srcDir, destDir) {
|
|
1342
2123
|
const { cp } = await import("fs/promises");
|
|
1343
|
-
await cp(srcDir,
|
|
2124
|
+
await cp(srcDir, join6(destDir, "spec"), { recursive: true });
|
|
1344
2125
|
await cp(srcDir, destDir, { recursive: true, force: false });
|
|
1345
2126
|
debug6("Spec files copied from %s to %s", srcDir, destDir);
|
|
1346
2127
|
}
|
|
@@ -1368,36 +2149,54 @@ async function getGitDiff(dir) {
|
|
|
1368
2149
|
return "";
|
|
1369
2150
|
}
|
|
1370
2151
|
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
args.push("--model", model);
|
|
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(" ")}`;
|
|
1376
2158
|
}
|
|
1377
|
-
|
|
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);
|
|
2171
|
+
return new Promise((resolve7) => {
|
|
2172
|
+
const proc = spawn("sh", ["-c", cmd], {
|
|
1378
2173
|
cwd: dir,
|
|
1379
|
-
|
|
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"]
|
|
1380
2179
|
});
|
|
1381
2180
|
let stdout = "";
|
|
1382
|
-
let stderr = "";
|
|
1383
2181
|
proc.stdout?.on("data", (chunk) => {
|
|
1384
2182
|
stdout += chunk.toString();
|
|
1385
2183
|
});
|
|
1386
2184
|
proc.stderr?.on("data", (chunk) => {
|
|
1387
|
-
stderr += chunk.toString();
|
|
1388
2185
|
process.stderr.write(chunk);
|
|
1389
2186
|
});
|
|
1390
2187
|
proc.on("close", (code) => {
|
|
1391
2188
|
resolve7({ stdout, exitCode: code ?? 0 });
|
|
1392
2189
|
});
|
|
1393
2190
|
proc.on("error", (err) => {
|
|
1394
|
-
debug6("
|
|
2191
|
+
debug6("%s spawn error: %O", harness, err);
|
|
1395
2192
|
resolve7({ stdout: "", exitCode: 1 });
|
|
1396
2193
|
});
|
|
1397
2194
|
});
|
|
1398
2195
|
}
|
|
1399
|
-
function parseTokensFromOutput(output) {
|
|
2196
|
+
function parseTokensFromOutput(output, model) {
|
|
1400
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;
|
|
1401
2200
|
try {
|
|
1402
2201
|
const lines = output.trim().split("\n");
|
|
1403
2202
|
for (const line of lines) {
|
|
@@ -1416,7 +2215,13 @@ function parseTokensFromOutput(output) {
|
|
|
1416
2215
|
const output_tokens = parsed.usage?.output_tokens ?? parsed.usage?.completion_tokens ?? 0;
|
|
1417
2216
|
if (input > 0 || output_tokens > 0) return input + output_tokens;
|
|
1418
2217
|
if (typeof parsed.cost_usd === "number" && parsed.cost_usd > 0) {
|
|
1419
|
-
|
|
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);
|
|
1420
2225
|
}
|
|
1421
2226
|
}
|
|
1422
2227
|
} catch {
|
|
@@ -1454,47 +2259,25 @@ function parseTokensFromOutput(output) {
|
|
|
1454
2259
|
function parseIntComma(s) {
|
|
1455
2260
|
return parseInt(s.replace(/,/g, ""), 10) || 0;
|
|
1456
2261
|
}
|
|
1457
|
-
async function checkCompletion(dir) {
|
|
1458
|
-
const fixPlanEmpty = await isFixPlanEmpty(dir);
|
|
1459
|
-
if (!fixPlanEmpty) {
|
|
1460
|
-
return {
|
|
1461
|
-
isComplete: false,
|
|
1462
|
-
results: await evaluateSuccessCriteria(dir).catch(() => [])
|
|
1463
|
-
};
|
|
1464
|
-
}
|
|
1465
|
-
const testsPass = await runTests(dir);
|
|
1466
|
-
if (!testsPass) {
|
|
1467
|
-
return {
|
|
1468
|
-
isComplete: false,
|
|
1469
|
-
results: await evaluateSuccessCriteria(dir).catch(() => [])
|
|
1470
|
-
};
|
|
1471
|
-
}
|
|
1472
|
-
const criteriaResults = await evaluateSuccessCriteria(dir);
|
|
1473
|
-
const allPassed = criteriaResults.every((r) => r.passed);
|
|
1474
|
-
return {
|
|
1475
|
-
isComplete: allPassed,
|
|
1476
|
-
results: criteriaResults
|
|
1477
|
-
};
|
|
1478
|
-
}
|
|
1479
2262
|
async function isFixPlanEmpty(dir) {
|
|
1480
2263
|
try {
|
|
1481
|
-
const content = await
|
|
2264
|
+
const content = await readFile5(join6(dir, "TASKS.md"), "utf-8");
|
|
1482
2265
|
const hasUncheckedItems = /^- \[ \]/m.test(content);
|
|
1483
2266
|
return !hasUncheckedItems;
|
|
1484
2267
|
} catch {
|
|
1485
2268
|
return true;
|
|
1486
2269
|
}
|
|
1487
2270
|
}
|
|
1488
|
-
async function
|
|
2271
|
+
async function runTestsWithOutput(dir) {
|
|
1489
2272
|
const testRunners = [
|
|
1490
2273
|
{ file: "package.json", cmd: "npm test -- --run 2>&1" },
|
|
1491
2274
|
{ file: "vitest.config.ts", cmd: "npx vitest run 2>&1" },
|
|
1492
|
-
{ file: "pytest.ini", cmd: "python -m pytest --tb=
|
|
2275
|
+
{ file: "pytest.ini", cmd: "python -m pytest --tb=short -q 2>&1" },
|
|
1493
2276
|
{ file: "Makefile", cmd: "make test 2>&1" }
|
|
1494
2277
|
];
|
|
1495
2278
|
for (const runner of testRunners) {
|
|
1496
2279
|
try {
|
|
1497
|
-
await access3(
|
|
2280
|
+
await access3(join6(dir, runner.file));
|
|
1498
2281
|
} catch {
|
|
1499
2282
|
continue;
|
|
1500
2283
|
}
|
|
@@ -1505,19 +2288,85 @@ async function runTests(dir) {
|
|
|
1505
2288
|
});
|
|
1506
2289
|
const combined = stdout + stderr;
|
|
1507
2290
|
const hasFailed = /\d+ failed|\d+ error/i.test(combined);
|
|
1508
|
-
return !hasFailed;
|
|
2291
|
+
return { passed: !hasFailed, output: combined };
|
|
1509
2292
|
} catch (err) {
|
|
1510
|
-
if (err && typeof err === "object"
|
|
1511
|
-
|
|
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
|
+
}
|
|
1512
2299
|
}
|
|
1513
2300
|
continue;
|
|
1514
2301
|
}
|
|
1515
2302
|
}
|
|
1516
|
-
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
|
+
}
|
|
1517
2366
|
}
|
|
1518
2367
|
async function evaluateSuccessCriteria(dir) {
|
|
1519
2368
|
try {
|
|
1520
|
-
const content = await
|
|
2369
|
+
const content = await readFile5(join6(dir, "SUCCESS_CRITERIA.md"), "utf-8");
|
|
1521
2370
|
const lines = content.split("\n");
|
|
1522
2371
|
const results = [];
|
|
1523
2372
|
for (const line of lines) {
|
|
@@ -1536,7 +2385,7 @@ async function evaluateSuccessCriteria(dir) {
|
|
|
1536
2385
|
}
|
|
1537
2386
|
async function loadExistingReport(dir) {
|
|
1538
2387
|
try {
|
|
1539
|
-
const raw = await
|
|
2388
|
+
const raw = await readFile5(join6(dir, "run-report.json"), "utf-8");
|
|
1540
2389
|
return JSON.parse(raw);
|
|
1541
2390
|
} catch {
|
|
1542
2391
|
return null;
|
|
@@ -1574,8 +2423,8 @@ async function handleRun(specPathOrId, opts) {
|
|
|
1574
2423
|
console.log(chalk6.yellow(` \u26A0 ${warning}`));
|
|
1575
2424
|
}
|
|
1576
2425
|
}
|
|
1577
|
-
const specYamlContent = await
|
|
1578
|
-
const specYamlRaw =
|
|
2426
|
+
const specYamlContent = await readFile6(join7(specDir, "spec.yaml"), "utf-8");
|
|
2427
|
+
const specYamlRaw = parseYaml4(specYamlContent);
|
|
1579
2428
|
const specYaml = specYamlSchema.parse(specYamlRaw);
|
|
1580
2429
|
console.log("");
|
|
1581
2430
|
console.log(chalk6.yellow("\u26A0 SECURITY WARNING"));
|
|
@@ -1593,8 +2442,13 @@ async function handleRun(specPathOrId, opts) {
|
|
|
1593
2442
|
if (authed && !opts.noTelemetry) {
|
|
1594
2443
|
await promptTelemetryOptIn();
|
|
1595
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
|
+
}
|
|
1596
2450
|
try {
|
|
1597
|
-
await checkClaudeCliInstalled();
|
|
2451
|
+
await checkClaudeCliInstalled(opts.harness);
|
|
1598
2452
|
} catch (err) {
|
|
1599
2453
|
console.log(chalk6.red(`
|
|
1600
2454
|
\u2717 ${err.message}`));
|
|
@@ -1602,13 +2456,43 @@ async function handleRun(specPathOrId, opts) {
|
|
|
1602
2456
|
}
|
|
1603
2457
|
const maxLoops = opts.maxLoops ? parseInt(opts.maxLoops, 10) : void 0;
|
|
1604
2458
|
const maxBudget = opts.maxBudget ? parseFloat(opts.maxBudget) : void 0;
|
|
2459
|
+
const harness = opts.harness ?? "claude-code";
|
|
1605
2460
|
console.log(chalk6.cyan(`
|
|
1606
2461
|
Running spec: ${chalk6.bold(specYaml.display_name)}`));
|
|
1607
2462
|
console.log(chalk6.gray(` Version: ${specYaml.version}`));
|
|
1608
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
|
+
}
|
|
1609
2468
|
console.log(chalk6.gray(` Max loops: ${maxLoops ?? 50}`));
|
|
1610
2469
|
console.log(chalk6.gray(` Estimated tokens: ${specYaml.estimated_tokens.toLocaleString()}`));
|
|
1611
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
|
+
}
|
|
1612
2496
|
console.log("");
|
|
1613
2497
|
const spinner = ora3({ text: "Starting loop iteration 1...", spinner: "dots" }).start();
|
|
1614
2498
|
try {
|
|
@@ -1630,12 +2514,17 @@ Running spec: ${chalk6.bold(specYaml.display_name)}`));
|
|
|
1630
2514
|
dryRun: opts.dryRun,
|
|
1631
2515
|
resumeRunId: opts.resume,
|
|
1632
2516
|
outputDir: opts.output,
|
|
1633
|
-
|
|
2517
|
+
harness: opts.harness,
|
|
2518
|
+
workdir: opts.workdir,
|
|
2519
|
+
cliVersion: CLI_VERSION,
|
|
2520
|
+
steeringQueue
|
|
1634
2521
|
},
|
|
1635
2522
|
(iteration) => {
|
|
1636
2523
|
spinner.text = `Loop ${iteration.iteration}: ${iteration.tokens.toLocaleString()} tokens, ${(iteration.durationMs / 1e3).toFixed(1)}s`;
|
|
1637
2524
|
}
|
|
1638
2525
|
);
|
|
2526
|
+
process.stdin.removeListener("data", steeringDataHandler);
|
|
2527
|
+
process.stdin.pause();
|
|
1639
2528
|
const { report } = result;
|
|
1640
2529
|
const statusColor = report.status === "success" ? chalk6.green : report.status === "stall" || report.status === "budget_exceeded" ? chalk6.yellow : chalk6.red;
|
|
1641
2530
|
spinner.stop();
|
|
@@ -1647,6 +2536,9 @@ Running spec: ${chalk6.bold(specYaml.display_name)}`));
|
|
|
1647
2536
|
console.log(` Tokens: ${report.totalTokens.toLocaleString()}`);
|
|
1648
2537
|
console.log(` Cost: $${report.totalCostUsd.toFixed(4)}`);
|
|
1649
2538
|
console.log(` Time: ${report.totalTimeMinutes.toFixed(1)} minutes`);
|
|
2539
|
+
if (report.steeringActionCount && report.steeringActionCount > 0) {
|
|
2540
|
+
console.log(` Steering Actions: ${report.steeringActionCount}`);
|
|
2541
|
+
}
|
|
1650
2542
|
console.log(` Run ID: ${chalk6.gray(report.runId)}`);
|
|
1651
2543
|
console.log(` Output: ${chalk6.gray(result.outputDir)}`);
|
|
1652
2544
|
if (report.successCriteriaResults.length > 0) {
|
|
@@ -1758,7 +2650,7 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1758
2650
|
debug7("Got download URL for %s@%s", scopedName, resolvedVersion);
|
|
1759
2651
|
const { tmpdir } = await import("os");
|
|
1760
2652
|
const { randomUUID: randomUUID2 } = await import("crypto");
|
|
1761
|
-
const tempDir =
|
|
2653
|
+
const tempDir = join7(tmpdir(), `specmarket-${randomUUID2()}`);
|
|
1762
2654
|
await mkdir4(tempDir, { recursive: true });
|
|
1763
2655
|
let response;
|
|
1764
2656
|
try {
|
|
@@ -1775,7 +2667,7 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1775
2667
|
throw err;
|
|
1776
2668
|
}
|
|
1777
2669
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1778
|
-
const zipPath =
|
|
2670
|
+
const zipPath = join7(tempDir, "spec.zip");
|
|
1779
2671
|
await writeFileFn(zipPath, buffer);
|
|
1780
2672
|
const { execAsync: execAsync2 } = await import("./exec-K3BOXX3C.js");
|
|
1781
2673
|
await execAsync2(`unzip -q "${zipPath}" -d "${tempDir}"`);
|
|
@@ -1787,7 +2679,13 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1787
2679
|
return { specDir: tempDir, registrySpecId };
|
|
1788
2680
|
}
|
|
1789
2681
|
function createRunCommand() {
|
|
1790
|
-
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").
|
|
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) => {
|
|
1791
2689
|
try {
|
|
1792
2690
|
await handleRun(pathOrId, opts);
|
|
1793
2691
|
} catch (err) {
|
|
@@ -2145,9 +3043,9 @@ function createInfoCommand() {
|
|
|
2145
3043
|
import { Command as Command9 } from "commander";
|
|
2146
3044
|
import chalk9 from "chalk";
|
|
2147
3045
|
import ora4 from "ora";
|
|
2148
|
-
import { readFile as
|
|
2149
|
-
import { join as
|
|
2150
|
-
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";
|
|
2151
3049
|
import { createWriteStream } from "fs";
|
|
2152
3050
|
async function handlePublish(specPath, opts = {}) {
|
|
2153
3051
|
const creds = await requireAuth();
|
|
@@ -2168,8 +3066,8 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
2168
3066
|
spinner.succeed("Spec validated");
|
|
2169
3067
|
}
|
|
2170
3068
|
spinner.start("Reading spec metadata...");
|
|
2171
|
-
const specYamlContent = await
|
|
2172
|
-
const specYamlRaw =
|
|
3069
|
+
const specYamlContent = await readFile7(join8(dir, "spec.yaml"), "utf-8");
|
|
3070
|
+
const specYamlRaw = parseYaml5(specYamlContent);
|
|
2173
3071
|
const specYaml = specYamlSchema.parse(specYamlRaw);
|
|
2174
3072
|
spinner.succeed(`Loaded spec: ${specYaml.display_name} v${specYaml.version}`);
|
|
2175
3073
|
const client = await getConvexClient(creds.token);
|
|
@@ -2184,7 +3082,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
2184
3082
|
spinner.succeed("Spec archive created");
|
|
2185
3083
|
spinner.start("Uploading spec to registry...");
|
|
2186
3084
|
const uploadUrl = await client.mutation(api2.specs.generateUploadUrl, {});
|
|
2187
|
-
const zipContent = await
|
|
3085
|
+
const zipContent = await readFile7(zipPath);
|
|
2188
3086
|
const uploadResponse = await fetch(uploadUrl, {
|
|
2189
3087
|
method: "POST",
|
|
2190
3088
|
headers: { "Content-Type": "application/zip" },
|
|
@@ -2196,7 +3094,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
2196
3094
|
const { storageId } = await uploadResponse.json();
|
|
2197
3095
|
spinner.succeed("Spec uploaded");
|
|
2198
3096
|
spinner.start("Publishing to registry...");
|
|
2199
|
-
const readme = await
|
|
3097
|
+
const readme = await readFile7(join8(dir, "SPEC.md"), "utf-8").catch(() => void 0);
|
|
2200
3098
|
const publishResult = await client.mutation(api2.specs.publish, {
|
|
2201
3099
|
slug: specYaml.name,
|
|
2202
3100
|
displayName: specYaml.display_name,
|
|
@@ -2210,6 +3108,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
2210
3108
|
specStorageId: storageId,
|
|
2211
3109
|
readme,
|
|
2212
3110
|
runner: specYaml.runner,
|
|
3111
|
+
specFormat: validation.format,
|
|
2213
3112
|
minModel: specYaml.min_model,
|
|
2214
3113
|
estimatedTokens: specYaml.estimated_tokens,
|
|
2215
3114
|
estimatedCostUsd: specYaml.estimated_cost_usd,
|
|
@@ -2238,7 +3137,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
2238
3137
|
async function createSpecZip(dir) {
|
|
2239
3138
|
const { tmpdir } = await import("os");
|
|
2240
3139
|
const { randomUUID: randomUUID2 } = await import("crypto");
|
|
2241
|
-
const zipPath =
|
|
3140
|
+
const zipPath = join8(tmpdir(), `spec-${randomUUID2()}.zip`);
|
|
2242
3141
|
const archiver = (await import("archiver")).default;
|
|
2243
3142
|
const output = createWriteStream(zipPath);
|
|
2244
3143
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
@@ -2269,9 +3168,9 @@ function createPublishCommand() {
|
|
|
2269
3168
|
import { Command as Command10 } from "commander";
|
|
2270
3169
|
import chalk10 from "chalk";
|
|
2271
3170
|
import ora5 from "ora";
|
|
2272
|
-
import { mkdir as mkdir5, writeFile as writeFile4, readFile as
|
|
2273
|
-
import { join as
|
|
2274
|
-
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";
|
|
2275
3174
|
async function handleFork(specId, targetPath) {
|
|
2276
3175
|
const creds = await requireAuth();
|
|
2277
3176
|
const spinner = ora5("Loading spec info...").start();
|
|
@@ -2299,9 +3198,9 @@ async function handleFork(specId, targetPath) {
|
|
|
2299
3198
|
const targetDir = resolve6(targetPath ?? spec.slug);
|
|
2300
3199
|
spinner.text = `Extracting to ${targetDir}...`;
|
|
2301
3200
|
await downloadAndExtract(url, targetDir);
|
|
2302
|
-
const specYamlPath =
|
|
2303
|
-
const specYamlContent = await
|
|
2304
|
-
const specYamlData =
|
|
3201
|
+
const specYamlPath = join9(targetDir, "spec.yaml");
|
|
3202
|
+
const specYamlContent = await readFile8(specYamlPath, "utf-8");
|
|
3203
|
+
const specYamlData = parseYaml6(specYamlContent);
|
|
2305
3204
|
specYamlData["forked_from_id"] = spec._id;
|
|
2306
3205
|
specYamlData["forked_from_version"] = spec.currentVersion;
|
|
2307
3206
|
specYamlData["version"] = "1.0.0";
|
|
@@ -2334,7 +3233,7 @@ async function downloadAndExtract(url, targetDir) {
|
|
|
2334
3233
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
2335
3234
|
const { tmpdir } = await import("os");
|
|
2336
3235
|
const { randomUUID: randomUUID2 } = await import("crypto");
|
|
2337
|
-
const zipPath =
|
|
3236
|
+
const zipPath = join9(tmpdir(), `fork-${randomUUID2()}.zip`);
|
|
2338
3237
|
const { writeFile: writeFileFn2, unlink: unlink2 } = await import("fs/promises");
|
|
2339
3238
|
await writeFileFn2(zipPath, buffer);
|
|
2340
3239
|
await mkdir5(targetDir, { recursive: true });
|
|
@@ -2361,15 +3260,15 @@ function createForkCommand() {
|
|
|
2361
3260
|
// src/commands/report.ts
|
|
2362
3261
|
import { Command as Command11 } from "commander";
|
|
2363
3262
|
import chalk11 from "chalk";
|
|
2364
|
-
import { readFile as
|
|
2365
|
-
import { join as
|
|
3263
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
3264
|
+
import { join as join10 } from "path";
|
|
2366
3265
|
import { homedir as homedir3 } from "os";
|
|
2367
3266
|
async function handleReport(runId) {
|
|
2368
|
-
const localPath =
|
|
3267
|
+
const localPath = join10(homedir3(), CONFIG_PATHS.RUNS_DIR, runId, "run-report.json");
|
|
2369
3268
|
let report = null;
|
|
2370
3269
|
let source = "local";
|
|
2371
3270
|
try {
|
|
2372
|
-
const raw = await
|
|
3271
|
+
const raw = await readFile9(localPath, "utf-8");
|
|
2373
3272
|
report = JSON.parse(raw);
|
|
2374
3273
|
source = "local";
|
|
2375
3274
|
} catch {
|
|
@@ -2664,16 +3563,11 @@ async function handleIssuesList(specRef, opts) {
|
|
|
2664
3563
|
const result = await client.query(api2.issues.list, {
|
|
2665
3564
|
specId: spec._id,
|
|
2666
3565
|
status: statusFilter,
|
|
3566
|
+
labels: opts.label ? [opts.label] : void 0,
|
|
2667
3567
|
paginationOpts: { numItems: 50, cursor: null }
|
|
2668
3568
|
});
|
|
2669
3569
|
spinner.stop();
|
|
2670
|
-
|
|
2671
|
-
if (opts.label) {
|
|
2672
|
-
const label = opts.label.toLowerCase();
|
|
2673
|
-
issues = issues.filter(
|
|
2674
|
-
(i) => i.labels.some((l) => l.toLowerCase() === label)
|
|
2675
|
-
);
|
|
2676
|
-
}
|
|
3570
|
+
const issues = result.page;
|
|
2677
3571
|
if (issues.length === 0) {
|
|
2678
3572
|
const statusLabel = statusFilter ?? "any";
|
|
2679
3573
|
console.log(chalk13.gray(`No ${statusLabel} issues for ${spec.scopedName}`));
|