@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/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-DLEMNRTH.js";
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 config = await import("./config-OAU6SJLC.js").then((m) => m.loadConfig());
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 join2, resolve } from "path";
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 handleInit(opts) {
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: "name",
501
- message: "Spec name (lowercase, hyphens only):",
502
- default: opts.name ?? "my-spec",
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: "displayName",
508
- message: "Display name:",
509
- default: (answers2) => answers2.name.split("-").map((w) => w[0]?.toUpperCase() + w.slice(1)).join(" ")
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, press Enter to skip):",
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
- const targetDir = resolve(opts.path ?? answers.name);
544
- const spinner = ora2(`Creating spec directory at ${targetDir}...`).start();
545
- try {
546
- await mkdir2(targetDir, { recursive: true });
547
- await mkdir2(join2(targetDir, "stdlib"), { recursive: true });
548
- const data = {
549
- name: answers.name,
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 directoryExists(dirPath) {
720
+ async function dirHasFiles(dir) {
607
721
  try {
608
- await access(dirPath);
609
- return true;
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 hasStoryFiles(dir) {
615
- try {
616
- const entries = await readdir(dir, { withFileTypes: true });
617
- return entries.some(
618
- (e) => e.isFile() && e.name.startsWith("story-") && e.name.endsWith(".md")
619
- );
620
- } catch {
621
- return false;
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
- async function hasMarkdownFiles(dir) {
625
- try {
626
- const entries = await readdir(dir, { withFileTypes: true });
627
- for (const entry of entries) {
628
- if (entry.isFile() && entry.name.endsWith(".md")) return true;
629
- if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
630
- const found = await hasMarkdownFiles(join3(dir, entry.name));
631
- if (found) return true;
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
- return false;
635
- } catch {
636
- return false;
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
- async function tryReadSidecar(dir) {
640
- const path = join3(dir, SIDECAR_FILENAME);
641
- if (!await fileExists(path)) return null;
642
- try {
643
- const raw = await readFile2(path, "utf-8");
644
- const parsed = parseYaml(raw);
645
- if (parsed && typeof parsed === "object" && "spec_format" in parsed) {
646
- const fmt = parsed.spec_format;
647
- if (typeof fmt === "string" && fmt.length > 0) return { spec_format: fmt };
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
- return null;
650
- } catch {
651
- return null;
652
- }
653
- }
654
- async function detectSpecFormat(dir) {
655
- const sidecar = await tryReadSidecar(dir);
656
- if (sidecar) {
657
- return {
658
- format: sidecar.spec_format,
659
- detectedBy: "sidecar",
660
- confidence: "high"
661
- };
662
- }
663
- const hasSpecYaml = await fileExists(join3(dir, "spec.yaml"));
664
- const hasPromptMd = await fileExists(join3(dir, "PROMPT.md"));
665
- const hasSuccessCriteria = await fileExists(join3(dir, "SUCCESS_CRITERIA.md"));
666
- if (hasSpecYaml && hasPromptMd && hasSuccessCriteria) {
667
- return {
668
- format: "specmarket-legacy",
669
- detectedBy: "heuristic",
670
- confidence: "high"
671
- };
672
- }
673
- if (hasPromptMd && hasSuccessCriteria) {
674
- return {
675
- format: "specmarket-legacy",
676
- detectedBy: "heuristic",
677
- confidence: "high"
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
- const prdJsonPath = join3(dir, "prd.json");
708
- if (await fileExists(prdJsonPath)) {
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
- const raw = await readFile2(prdJsonPath, "utf-8");
711
- const data = JSON.parse(raw);
712
- if (data && typeof data === "object") {
713
- return {
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 readdir2(currentDir, { withFileTypes: true });
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 validateLegacySpec(dir, errors, warnings) {
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
- const detection = await detectSpecFormat(dir);
1192
+ let format;
1193
+ let formatDetectedBy = "sidecar";
930
1194
  try {
931
- const entries = await readdir2(dir, { withFileTypes: true });
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
- if (await fileExists(sidecarPath)) {
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
- `sidecar estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
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
- `sidecar estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
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
- `sidecar estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
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: detection.format,
1065
- formatDetectedBy: detection.detectedBy
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(`Detected format: ${result.format}`));
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 readFile5, mkdir as mkdir4, writeFile as writeFileFn } from "fs/promises";
1110
- import { join as join6, resolve as resolve4, isAbsolute } from "path";
1111
- import { parse as parseYaml3 } from "yaml";
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-OAU6SJLC.js");
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 readFile4, access as access3 } from "fs/promises";
1182
- import { join as join5 } from "path";
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("which claude");
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
- `Claude CLI is not installed or not in your PATH.
1870
+ `Harness "${h}" binary "${binaryName}" is not installed or not in your PATH.
1196
1871
 
1197
- Installation instructions:
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 = join5(homedir2(), CONFIG_PATHS.RUNS_DIR);
1210
- const runDir = opts.outputDir ?? join5(runsBaseDir, runId);
1211
- await mkdir3(runDir, { recursive: true });
1212
- debug6("Run directory: %s", runDir);
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 result = await executeClaudeLoop(runDir, opts.model);
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 tokensThisLoop = parseTokensFromOutput(result.stdout);
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
- join5(runDir, `iteration-${i}.json`),
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 completionCheck = await checkCompletion(runDir);
1305
- if (completionCheck.isComplete) {
1306
- debug6("Success criteria met at iteration %d", i);
1307
- successCriteriaResults = completionCheck.results;
1308
- finalStatus = "success";
1309
- break;
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
- join5(runDir, "run-report.json"),
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, join5(destDir, "spec"), { recursive: true });
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
- async function executeClaudeLoop(dir, model) {
1372
- return new Promise((resolve7) => {
1373
- const args = ["--print", "--output-format", "json"];
1374
- if (model) {
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
- const proc = spawn("sh", ["-c", `cat PROMPT.md | claude ${args.join(" ")}`], {
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
- stdio: ["inherit", "pipe", "pipe"]
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("claude spawn error: %O", err);
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
- return Math.round(parsed.cost_usd / 9e-6);
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 readFile4(join5(dir, "TASKS.md"), "utf-8");
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 runTests(dir) {
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=no -q 2>&1" },
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(join5(dir, runner.file));
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" && "code" in err && typeof err.code === "number") {
1511
- return false;
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 readFile4(join5(dir, "SUCCESS_CRITERIA.md"), "utf-8");
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 readFile4(join5(dir, "run-report.json"), "utf-8");
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 readFile5(join6(specDir, "spec.yaml"), "utf-8");
1578
- const specYamlRaw = parseYaml3(specYamlContent);
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
- cliVersion: CLI_VERSION
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 = join6(tmpdir(), `specmarket-${randomUUID2()}`);
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 = join6(tempDir, "spec.zip");
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").action(async (pathOrId, opts) => {
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 readFile6 } from "fs/promises";
2149
- import { join as join7, resolve as resolve5 } from "path";
2150
- import { parse as parseYaml4 } from "yaml";
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 readFile6(join7(dir, "spec.yaml"), "utf-8");
2172
- const specYamlRaw = parseYaml4(specYamlContent);
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 readFile6(zipPath);
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 readFile6(join7(dir, "SPEC.md"), "utf-8").catch(() => void 0);
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 = join7(tmpdir(), `spec-${randomUUID2()}.zip`);
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 readFile7 } from "fs/promises";
2273
- import { join as join8, resolve as resolve6 } from "path";
2274
- import { parse as parseYaml5, stringify as stringifyYaml } from "yaml";
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 = join8(targetDir, "spec.yaml");
2303
- const specYamlContent = await readFile7(specYamlPath, "utf-8");
2304
- const specYamlData = parseYaml5(specYamlContent);
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 = join8(tmpdir(), `fork-${randomUUID2()}.zip`);
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 readFile8 } from "fs/promises";
2365
- import { join as join9 } from "path";
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 = join9(homedir3(), CONFIG_PATHS.RUNS_DIR, runId, "run-report.json");
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 readFile8(localPath, "utf-8");
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
- let issues = result.page;
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}`));