@specmarket/cli 0.0.4 → 0.0.6

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