ai-spec-dev 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/index.ts CHANGED
@@ -75,6 +75,15 @@ import { generateRunId, RunLogger, setActiveLogger } from "../core/run-logger";
75
75
  import { RunSnapshot, setActiveSnapshot } from "../core/run-snapshot";
76
76
  import { computePromptHash } from "../core/prompt-hasher";
77
77
  import { runSelfEval, printSelfEval } from "../core/self-evaluator";
78
+ import { loadRunLogs, buildTrendReport, printTrendReport } from "../core/run-trend";
79
+ import {
80
+ assessDslRichness,
81
+ buildDslGapRefinementPrompt,
82
+ extractStructuralFindings,
83
+ buildStructuralAmendmentPrompt,
84
+ printDslGaps,
85
+ printStructuralFindings,
86
+ } from "../core/dsl-feedback";
78
87
 
79
88
  // ─── Config File ──────────────────────────────────────────────────────────────
80
89
 
@@ -524,6 +533,60 @@ program
524
533
  }
525
534
  }
526
535
 
536
+ // ── Loop 1: DSL Gap Feedback ──────────────────────────────────────────────
537
+ // Runs only in interactive mode (not --auto / --fast / --skip-dsl).
538
+ // Checks for common completeness gaps in the freshly-extracted DSL and
539
+ // offers a targeted spec refinement AI call to fill them before codegen.
540
+ // Zero extra AI calls unless the user explicitly opts in.
541
+ if (extractedDsl && !opts.auto && !opts.fast && !opts.skipDsl) {
542
+ const dslGaps = assessDslRichness(extractedDsl);
543
+
544
+ if (dslGaps.length > 0) {
545
+ printDslGaps(dslGaps);
546
+ runLogger.stageStart("dsl_gap_feedback", { gapCount: dslGaps.length, gaps: dslGaps.map((g) => g.code) });
547
+
548
+ const refineChoice = await select({
549
+ message: "How would you like to proceed?",
550
+ choices: [
551
+ { name: "🔧 Refine spec (AI fills the gaps, then re-extract DSL)", value: "refine" },
552
+ { name: "⏭ Skip — proceed with the current DSL", value: "skip" },
553
+ ],
554
+ });
555
+
556
+ if (refineChoice === "refine") {
557
+ console.log(chalk.blue(" Refining spec to fill DSL gaps..."));
558
+ try {
559
+ const refinedSpec = await specProvider.generate(
560
+ buildDslGapRefinementPrompt(finalSpec, dslGaps),
561
+ "You are a Senior Tech Lead doing a targeted spec revision. Output only the complete revised Markdown spec."
562
+ );
563
+ finalSpec = refinedSpec;
564
+ console.log(chalk.green(" ✔ Spec refined."));
565
+
566
+ // Re-extract DSL from the improved spec
567
+ console.log(chalk.blue(" Re-extracting DSL from refined spec..."));
568
+ const isFrontend2 = isFrontendDeps(context.dependencies);
569
+ const reExtractor = new DslExtractor(specProvider);
570
+ const reExtractedDsl = await reExtractor.extract(finalSpec, { auto: true, isFrontend: isFrontend2 });
571
+ if (reExtractedDsl) {
572
+ extractedDsl = reExtractedDsl;
573
+ console.log(chalk.green(` ✔ DSL re-extracted: ${extractedDsl.endpoints.length} endpoint(s), ${extractedDsl.models.length} model(s).`));
574
+ runLogger.stageEnd("dsl_gap_feedback", { action: "refined", endpoints: extractedDsl.endpoints.length, models: extractedDsl.models.length });
575
+ } else {
576
+ console.log(chalk.yellow(" ⚠ Re-extraction failed — keeping original DSL."));
577
+ runLogger.stageEnd("dsl_gap_feedback", { action: "refined_but_reextract_failed" });
578
+ }
579
+ } catch (err) {
580
+ console.log(chalk.yellow(` ⚠ Spec refinement failed: ${(err as Error).message} — keeping original DSL.`));
581
+ runLogger.stageEnd("dsl_gap_feedback", { action: "refinement_error", error: (err as Error).message });
582
+ }
583
+ } else {
584
+ runLogger.stageEnd("dsl_gap_feedback", { action: "skipped" });
585
+ console.log(chalk.gray(" Continuing with current DSL."));
586
+ }
587
+ }
588
+ }
589
+
527
590
  // ── Step 4: Git Worktree ──────────────────────────────────────────────────
528
591
  // Frontend projects (React / Vue / Next / React-Native) work directly on a
529
592
  // feature branch — no worktree needed. node_modules is not copied into
@@ -674,6 +737,82 @@ program
674
737
  await accumulateReviewKnowledge(specProvider, currentDir, reviewResult);
675
738
  }
676
739
 
740
+ // ── Loop 2: Review → DSL Structural Feedback ─────────────────────────────
741
+ // Runs only in interactive mode when a review was produced.
742
+ // Classifies Pass 1 (architecture) findings as design-level vs implementation-
743
+ // level issues. Design issues belong in the Spec/DSL — not just in §9.
744
+ // If found: offer to amend the spec → re-extract DSL → overwrite saved DSL file.
745
+ // The user can then run `ai-spec update --codegen` to regenerate affected files.
746
+ if (reviewResult && !opts.skipReview && !opts.auto && extractedDsl && savedDslFile) {
747
+ const structuralFindings = extractStructuralFindings(reviewResult);
748
+
749
+ if (structuralFindings.length > 0) {
750
+ printStructuralFindings(structuralFindings);
751
+ runLogger.stageStart("review_dsl_feedback", { findingCount: structuralFindings.length, categories: structuralFindings.map((f) => f.category) });
752
+
753
+ const savedSpecContent = await fs.readFile(specFile, "utf-8");
754
+
755
+ const patchChoice = await select({
756
+ message: "These are design issues in the Spec/DSL. How would you like to handle them?",
757
+ choices: [
758
+ { name: "🔧 Amend spec + update DSL (AI fixes the design issues, no regen yet)", value: "amend" },
759
+ { name: "📝 Note in §9 only (already done — no DSL change)", value: "note" },
760
+ { name: "⏭ Skip", value: "skip" },
761
+ ],
762
+ });
763
+
764
+ if (patchChoice === "amend") {
765
+ console.log(chalk.blue(" Amending spec to address structural findings..."));
766
+ try {
767
+ const amendedSpec = await specProvider.generate(
768
+ buildStructuralAmendmentPrompt(savedSpecContent, structuralFindings),
769
+ "You are a Senior Tech Lead doing a targeted spec correction. Output only the complete revised Markdown spec."
770
+ );
771
+
772
+ // Snapshot spec + DSL before overwriting so `ai-spec restore <runId>` works
773
+ await runSnapshot.snapshotFile(specFile);
774
+ if (savedDslFile) await runSnapshot.snapshotFile(savedDslFile);
775
+
776
+ // Overwrite the saved spec file with the amendment
777
+ await fs.writeFile(specFile, amendedSpec, "utf-8");
778
+ console.log(chalk.green(` ✔ Spec updated: ${specFile}`));
779
+
780
+ // Re-extract DSL from the amended spec
781
+ console.log(chalk.blue(" Re-extracting DSL from amended spec..."));
782
+ const isFrontend3 = isFrontendDeps(context.dependencies);
783
+ const amendExtractor = new DslExtractor(specProvider);
784
+ const amendedDsl = await amendExtractor.extract(amendedSpec, { auto: true, isFrontend: isFrontend3 });
785
+ if (amendedDsl) {
786
+ // Overwrite saved DSL file
787
+ const dslWriter = new DslExtractor(specProvider);
788
+ const newDslPath = await dslWriter.saveDsl(amendedDsl, specFile);
789
+ extractedDsl = amendedDsl;
790
+ console.log(chalk.green(` ✔ DSL updated: ${newDslPath}`));
791
+ console.log(chalk.cyan(
792
+ `\n Next step: run ${chalk.white("ai-spec update --codegen")} to regenerate files affected by the DSL change.`
793
+ ));
794
+ runLogger.stageEnd("review_dsl_feedback", {
795
+ action: "amended",
796
+ endpoints: amendedDsl.endpoints.length,
797
+ models: amendedDsl.models.length,
798
+ });
799
+ } else {
800
+ console.log(chalk.yellow(" ⚠ DSL re-extraction failed — spec was updated but DSL file unchanged."));
801
+ runLogger.stageEnd("review_dsl_feedback", { action: "amended_spec_only" });
802
+ }
803
+ } catch (err) {
804
+ console.log(chalk.yellow(` ⚠ Spec amendment failed: ${(err as Error).message}`));
805
+ runLogger.stageEnd("review_dsl_feedback", { action: "amendment_error", error: (err as Error).message });
806
+ }
807
+ } else {
808
+ runLogger.stageEnd("review_dsl_feedback", { action: patchChoice });
809
+ if (patchChoice === "note") {
810
+ console.log(chalk.gray(" Structural findings retained in §9. DSL unchanged."));
811
+ }
812
+ }
813
+ }
814
+ }
815
+
677
816
  // ── Step 10: Harness Self-Evaluation ──────────────────────────────────────
678
817
  // Zero AI calls — deterministic scoring from file-system state + review text.
679
818
  // Records harnessScore + promptHash in RunLog for cross-run trend analysis.
@@ -2253,4 +2392,142 @@ program
2253
2392
  }
2254
2393
  });
2255
2394
 
2395
+ // ═══════════════════════════════════════════════════════════════════════════════
2396
+ // Command: trend
2397
+ // ═══════════════════════════════════════════════════════════════════════════════
2398
+
2399
+ program
2400
+ .command("trend")
2401
+ .description("Show harness score trend across past create runs")
2402
+ .option("--last <n>", "Number of recent scored runs to show (default: 15)", "15")
2403
+ .option("--prompt <hash>", "Filter to a specific prompt hash (prefix match)")
2404
+ .option("--json", "Output raw JSON instead of formatted table")
2405
+ .action(async (opts: { last: string; prompt?: string; json?: boolean }) => {
2406
+ const currentDir = process.cwd();
2407
+ const last = parseInt(opts.last, 10) || 15;
2408
+
2409
+ const logs = await loadRunLogs(currentDir);
2410
+ if (logs.length === 0) {
2411
+ console.log(chalk.yellow(
2412
+ "\n No run logs found. Run `ai-spec create` at least once to start tracking.\n"
2413
+ ));
2414
+ return;
2415
+ }
2416
+
2417
+ const report = buildTrendReport(logs, {
2418
+ last,
2419
+ promptFilter: opts.prompt,
2420
+ });
2421
+
2422
+ if (opts.json) {
2423
+ console.log(JSON.stringify(report, null, 2));
2424
+ return;
2425
+ }
2426
+
2427
+ printTrendReport(report, currentDir);
2428
+ });
2429
+
2430
+ // ═══════════════════════════════════════════════════════════════════════════════
2431
+ // Command: logs
2432
+ // ═══════════════════════════════════════════════════════════════════════════════
2433
+
2434
+ program
2435
+ .command("logs")
2436
+ .description("List recent run logs with stage timing")
2437
+ .argument("[runId]", "Show detailed stage breakdown for a specific run ID")
2438
+ .option("--last <n>", "Number of runs to list (default: 10)", "10")
2439
+ .action(async (runId: string | undefined, opts: { last: string }) => {
2440
+ const currentDir = process.cwd();
2441
+ const logDir = path.join(currentDir, ".ai-spec-logs");
2442
+
2443
+ if (!(await fs.pathExists(logDir))) {
2444
+ console.log(chalk.yellow("\n No run logs found (.ai-spec-logs/ does not exist).\n"));
2445
+ return;
2446
+ }
2447
+
2448
+ if (runId) {
2449
+ // ── Detail view for a single run ──────────────────────────────────────
2450
+ const logPath = path.join(logDir, `${runId}.json`);
2451
+ if (!(await fs.pathExists(logPath))) {
2452
+ console.log(chalk.red(`\n Run not found: ${runId}\n`));
2453
+ return;
2454
+ }
2455
+ const log = await fs.readJson(logPath);
2456
+
2457
+ console.log(chalk.cyan(`\n─── Run: ${log.runId} ─────────────────────────────────`));
2458
+ console.log(chalk.gray(` Started : ${log.startedAt}`));
2459
+ if (log.endedAt) console.log(chalk.gray(` Ended : ${log.endedAt}`));
2460
+ if (log.totalDurationMs !== undefined)
2461
+ console.log(chalk.gray(` Duration: ${(log.totalDurationMs / 1000).toFixed(1)}s`));
2462
+ if (log.provider) console.log(chalk.gray(` Provider: ${log.provider} / ${log.model ?? "?"}`));
2463
+ if (log.promptHash) console.log(chalk.gray(` Prompt : ${log.promptHash}`));
2464
+ if (log.harnessScore !== undefined)
2465
+ console.log(chalk.white(` Score : ${log.harnessScore}/10`));
2466
+ if (log.filesWritten?.length)
2467
+ console.log(chalk.gray(` Files : ${log.filesWritten.length} written`));
2468
+ if (log.errors?.length)
2469
+ console.log(chalk.yellow(` Errors : ${log.errors.length}`));
2470
+
2471
+ if (log.entries?.length) {
2472
+ console.log(chalk.bold("\n Stages:\n"));
2473
+ const doneEvents = (log.entries as Array<{ event: string; data?: Record<string, unknown>; ts: string }>)
2474
+ .filter((e) => e.event.endsWith(":done") || e.event.endsWith(":failed"));
2475
+
2476
+ for (const entry of doneEvents) {
2477
+ const isOk = entry.event.endsWith(":done");
2478
+ const stage = entry.event.replace(/:done$|:failed$/, "");
2479
+ const dur = entry.data?.durationMs
2480
+ ? chalk.gray(` ${(Number(entry.data.durationMs) / 1000).toFixed(1)}s`)
2481
+ : "";
2482
+ const mark = isOk ? chalk.green("✔") : chalk.red("✘");
2483
+ console.log(` ${mark} ${stage.padEnd(20)}${dur}`);
2484
+ }
2485
+ }
2486
+ console.log(chalk.cyan("─".repeat(52)));
2487
+ return;
2488
+ }
2489
+
2490
+ // ── List view ─────────────────────────────────────────────────────────────
2491
+ const logs = await loadRunLogs(currentDir);
2492
+ const last = parseInt(opts.last, 10) || 10;
2493
+ const shown = logs.slice(0, last);
2494
+
2495
+ if (shown.length === 0) {
2496
+ console.log(chalk.yellow("\n No run logs found.\n"));
2497
+ return;
2498
+ }
2499
+
2500
+ console.log(chalk.cyan("\n─── Run Logs ────────────────────────────────────────────────"));
2501
+ console.log(chalk.gray(
2502
+ "\n " +
2503
+ "Run ID ".padEnd(26) +
2504
+ "Date " +
2505
+ "Score ".padStart(6) +
2506
+ " Files Dur\n"
2507
+ ));
2508
+
2509
+ for (const log of shown) {
2510
+ const date = log.startedAt.slice(0, 10);
2511
+ const score = log.harnessScore !== undefined
2512
+ ? (log.harnessScore >= 8 ? chalk.green : log.harnessScore >= 6 ? chalk.yellow : chalk.red)(
2513
+ log.harnessScore.toFixed(1).padStart(5)
2514
+ )
2515
+ : chalk.gray(" —");
2516
+ const files = String(log.filesWritten?.length ?? 0).padStart(5);
2517
+ const dur = log.totalDurationMs !== undefined
2518
+ ? chalk.gray((log.totalDurationMs / 1000).toFixed(0) + "s")
2519
+ : chalk.gray("—");
2520
+ const errMark = (log.errors?.length ?? 0) > 0
2521
+ ? chalk.yellow(` ⚠${log.errors.length}`)
2522
+ : "";
2523
+
2524
+ console.log(` ${chalk.white(log.runId.padEnd(25))} ${chalk.gray(date)} ${score} ${chalk.gray(files)} ${dur}${errMark}`);
2525
+ }
2526
+
2527
+ console.log(chalk.gray(`\n Showing ${shown.length} of ${logs.length} run(s) · logs: .ai-spec-logs/`));
2528
+ console.log(chalk.cyan("─".repeat(63)));
2529
+ console.log(chalk.gray(` Tip: ai-spec logs <runId> to see stage breakdown`));
2530
+ console.log(chalk.gray(` ai-spec trend to see score trend by prompt version\n`));
2531
+ });
2532
+
2256
2533
  program.parse();
package/cli/utils.ts ADDED
@@ -0,0 +1,83 @@
1
+ import * as path from "path";
2
+ import * as fs from "fs-extra";
3
+ import chalk from "chalk";
4
+ import { input, select } from "@inquirer/prompts";
5
+ import { CodeGenMode } from "../core/code-generator";
6
+ import { ENV_KEY_MAP } from "../core/spec-generator";
7
+ import { getSavedKey, saveKey, KEY_STORE_FILE } from "../core/key-store";
8
+
9
+ // ─── Config ───────────────────────────────────────────────────────────────────
10
+
11
+ export interface AiSpecConfig {
12
+ provider?: string;
13
+ model?: string;
14
+ codegen?: CodeGenMode;
15
+ codegenProvider?: string;
16
+ codegenModel?: string;
17
+ /** Minimum overall spec score (1-10) required to pass Approval Gate. 0 = disabled (default). */
18
+ minSpecScore?: number;
19
+ }
20
+
21
+ export const CONFIG_FILE = ".ai-spec.json";
22
+
23
+ export async function loadConfig(dir: string): Promise<AiSpecConfig> {
24
+ const p = path.join(dir, CONFIG_FILE);
25
+ if (await fs.pathExists(p)) {
26
+ return fs.readJson(p);
27
+ }
28
+ return {};
29
+ }
30
+
31
+ // ─── API Key Resolution ───────────────────────────────────────────────────────
32
+
33
+ export async function resolveApiKey(
34
+ providerName: string,
35
+ cliKey?: string
36
+ ): Promise<string> {
37
+ if (cliKey) return cliKey;
38
+
39
+ const envVar = ENV_KEY_MAP[providerName];
40
+ if (envVar && process.env[envVar]) return process.env[envVar]!;
41
+
42
+ const savedKey = await getSavedKey(providerName);
43
+ if (savedKey) {
44
+ const masked = savedKey.slice(0, 6) + "..." + savedKey.slice(-4);
45
+ const choice = await select({
46
+ message: `${providerName} API key (saved: ${masked}):`,
47
+ choices: [
48
+ { name: "Use saved key", value: "reuse" },
49
+ { name: "Enter a new key", value: "new" },
50
+ ],
51
+ });
52
+ if (choice === "reuse") return savedKey;
53
+ }
54
+
55
+ const newKey = await input({
56
+ message: `Enter your ${providerName} API key${envVar ? ` (or set ${envVar} env var)` : ""}:`,
57
+ validate: (v) => v.trim().length > 0 || "API key cannot be empty",
58
+ });
59
+ await saveKey(providerName, newKey.trim());
60
+ console.log(chalk.gray(` Key saved to ${KEY_STORE_FILE}`));
61
+ return newKey.trim();
62
+ }
63
+
64
+ // ─── Banner ───────────────────────────────────────────────────────────────────
65
+
66
+ export function printBanner(opts: {
67
+ specProvider: string;
68
+ specModel: string;
69
+ codegenMode: string;
70
+ codegenProvider: string;
71
+ codegenModel: string;
72
+ }) {
73
+ console.log(chalk.blue("\n" + "─".repeat(52)));
74
+ console.log(chalk.bold(" ai-spec — AI-driven Development Orchestrator"));
75
+ console.log(chalk.blue("─".repeat(52)));
76
+ console.log(chalk.gray(` Spec : ${opts.specProvider} / ${opts.specModel}`));
77
+ console.log(
78
+ chalk.gray(
79
+ ` Codegen : ${opts.codegenMode} (${opts.codegenProvider} / ${opts.codegenModel})`
80
+ )
81
+ );
82
+ console.log(chalk.blue("─".repeat(52) + "\n"));
83
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * dsl-feedback.ts — Two pipeline feedback loops for ai-spec create
3
+ *
4
+ * Loop 1 (DSL → Spec): after DSL extraction, detect sparse/incomplete DSL
5
+ * and offer a targeted spec refinement pass before codegen starts.
6
+ *
7
+ * Loop 2 (Review → DSL): after 3-pass review, detect design-level findings
8
+ * (as opposed to implementation issues) and offer to amend the spec + DSL
9
+ * so the next update/regen starts from a corrected contract.
10
+ *
11
+ * Design constraints:
12
+ * - Both loops are SKIPPED in --auto / --fast / --skip-dsl modes.
13
+ * - Zero extra AI calls until the user explicitly opts in.
14
+ * - Non-blocking: user can always skip.
15
+ */
16
+
17
+ import chalk from "chalk";
18
+ import { SpecDSL } from "./dsl-types";
19
+
20
+ // ─── Loop 1 Types ─────────────────────────────────────────────────────────────
21
+
22
+ export interface DslGap {
23
+ /** Short machine key for RunLog serialisation */
24
+ code: "sparse_model" | "missing_errors" | "generic_endpoint_desc" | "no_models_no_endpoints";
25
+ /** Human-readable message shown to the user */
26
+ message: string;
27
+ /** Concrete suggestion injected into the refinement prompt */
28
+ hint: string;
29
+ }
30
+
31
+ // ─── Loop 1: DSL Richness Assessment ─────────────────────────────────────────
32
+
33
+ /**
34
+ * Inspect a freshly-extracted DSL for common completeness gaps.
35
+ * Returns a list of DslGap objects (empty = DSL looks adequate).
36
+ *
37
+ * All checks are pure heuristics — zero AI calls.
38
+ */
39
+ export function assessDslRichness(dsl: SpecDSL): DslGap[] {
40
+ const gaps: DslGap[] = [];
41
+
42
+ // ── No endpoints AND no models ────────────────────────────────────────────
43
+ if (dsl.endpoints.length === 0 && dsl.models.length === 0) {
44
+ gaps.push({
45
+ code: "no_models_no_endpoints",
46
+ message: "DSL has no endpoints and no models — spec may be too abstract for structured extraction",
47
+ hint: "Please add explicit API endpoint definitions (method, path, request/response) and any data models that this feature requires.",
48
+ });
49
+ return gaps; // no point checking the rest
50
+ }
51
+
52
+ // ── Endpoints with very generic / short descriptions ─────────────────────
53
+ const GENERIC_DESC_KEYWORDS = ["handles", "processes", "manages", "操作", "处理", "管理"];
54
+ const GENERIC_DESC_MIN_LEN = 15;
55
+
56
+ for (const ep of dsl.endpoints) {
57
+ const desc = (ep.description ?? "").trim();
58
+ const isGeneric =
59
+ desc.length < GENERIC_DESC_MIN_LEN ||
60
+ GENERIC_DESC_KEYWORDS.some((kw) => desc.toLowerCase().startsWith(kw));
61
+
62
+ if (isGeneric) {
63
+ gaps.push({
64
+ code: "generic_endpoint_desc",
65
+ message: `Endpoint ${ep.method} ${ep.path} has a vague description: "${desc}"`,
66
+ hint: `Clarify what ${ep.method} ${ep.path} does: what inputs are required, what the success response contains, and what business rule it enforces.`,
67
+ });
68
+ }
69
+ }
70
+
71
+ // ── Endpoints with no error definitions (but spec text likely mentions them) ──
72
+ const endpointsWithoutErrors = dsl.endpoints.filter(
73
+ (ep) => !ep.errors || ep.errors.length === 0
74
+ );
75
+ if (endpointsWithoutErrors.length > 0 && dsl.endpoints.length >= 2) {
76
+ gaps.push({
77
+ code: "missing_errors",
78
+ message: `${endpointsWithoutErrors.length}/${dsl.endpoints.length} endpoints have no error definitions`,
79
+ hint: `For each endpoint, specify at least the main error cases: e.g. 400 validation errors, 401 auth failures, 404 not found, 409 conflict. Include an error code (e.g. INVALID_INPUT) and description for each.`,
80
+ });
81
+ }
82
+
83
+ // ── Models with fewer than 2 fields ──────────────────────────────────────
84
+ for (const model of dsl.models) {
85
+ if (!model.fields || model.fields.length < 2) {
86
+ gaps.push({
87
+ code: "sparse_model",
88
+ message: `Model "${model.name}" has only ${model.fields?.length ?? 0} field(s) — likely incomplete`,
89
+ hint: `List all fields for "${model.name}" with their types and whether they are required. Include at minimum an id, created_at, and the core domain fields this model needs.`,
90
+ });
91
+ }
92
+ }
93
+
94
+ return gaps;
95
+ }
96
+
97
+ // ─── Loop 1: Targeted Spec Refinement Prompt ─────────────────────────────────
98
+
99
+ /**
100
+ * Build a targeted AI refinement prompt that focuses the LLM on filling
101
+ * only the specific gaps detected by `assessDslRichness`.
102
+ */
103
+ export function buildDslGapRefinementPrompt(spec: string, gaps: DslGap[]): string {
104
+ const gapList = gaps
105
+ .map((g, i) => `${i + 1}. [${g.code}] ${g.message}\n → ${g.hint}`)
106
+ .join("\n\n");
107
+
108
+ return `The following feature spec has been structurally analysed. The DSL extracted from it was found to be incomplete in these specific areas:
109
+
110
+ ${gapList}
111
+
112
+ Your task: revise the spec below to address ONLY the gaps listed above.
113
+ - Do NOT change the overall feature scope or business logic.
114
+ - Do NOT rewrite sections that are already complete.
115
+ - Add missing error cases, clarify vague endpoint descriptions, complete sparse model field lists.
116
+ - Output ONLY the complete revised Markdown spec. No preamble, no explanation.
117
+
118
+ === Current Spec ===
119
+ ${spec}`;
120
+ }
121
+
122
+ // ─── Loop 2 Types ─────────────────────────────────────────────────────────────
123
+
124
+ export interface StructuralFinding {
125
+ /** Short label for display + RunLog */
126
+ category: "auth_design" | "model_design" | "api_contract" | "layer_violation" | "other_design";
127
+ description: string;
128
+ }
129
+
130
+ // ─── Loop 2: Review Structural Issue Classifier ───────────────────────────────
131
+
132
+ /**
133
+ * Parse a 3-pass review text to extract Pass 1 (architecture) findings
134
+ * that indicate design-level issues in the Spec/DSL — as opposed to
135
+ * implementation-level issues that belong in §9 knowledge.
136
+ *
137
+ * Returns an empty array if no structural issues are found or if the
138
+ * review score for Pass 1 is high (≥ 8), indicating overall approval.
139
+ */
140
+ export function extractStructuralFindings(reviewText: string): StructuralFinding[] {
141
+ // Split by the separator used between passes ("─────...")
142
+ const parts = reviewText.split(/─{20,}/);
143
+ // Pass 1 is always the first section
144
+ const pass1Text = parts[0] ?? "";
145
+
146
+ // If Pass 1 scored well, treat as no structural issues
147
+ const pass1Score = extractPassScore(pass1Text);
148
+ if (pass1Score !== null && pass1Score >= 8) return [];
149
+
150
+ const findings: StructuralFinding[] = [];
151
+
152
+ // ── Auth / 认证 design issues ──────────────────────────────────────────
153
+ if (
154
+ /缺少认证|missing auth|auth.*false|未加认证|鉴权.*缺|endpoint.*public.*should/i.test(pass1Text)
155
+ ) {
156
+ const match = pass1Text.match(/[^。\n]*(?:缺少认证|missing auth|auth.*false|未加认证|鉴权.*缺|endpoint.*public.*should)[^。\n]*/i);
157
+ findings.push({
158
+ category: "auth_design",
159
+ description: match ? match[0].trim() : "One or more endpoints may have incorrect authentication requirements",
160
+ });
161
+ }
162
+
163
+ // ── API contract / 接口设计 issues ────────────────────────────────────
164
+ if (
165
+ /接口设计.*问题|接口.*不合理|API design|response.*missing|request.*missing|接口.*缺少/i.test(pass1Text)
166
+ ) {
167
+ const match = pass1Text.match(/[^。\n]*(?:接口设计.*问题|接口.*不合理|API design|response.*missing|接口.*缺少)[^。\n]*/i);
168
+ findings.push({
169
+ category: "api_contract",
170
+ description: match ? match[0].trim() : "API contract design may have issues",
171
+ });
172
+ }
173
+
174
+ // ── Model / 数据模型 design issues ────────────────────────────────────
175
+ if (
176
+ /模型.*缺少字段|model.*missing field|数据结构.*问题|schema.*incomplete|字段.*missing/i.test(pass1Text)
177
+ ) {
178
+ const match = pass1Text.match(/[^。\n]*(?:模型.*缺少字段|model.*missing field|数据结构.*问题|schema.*incomplete)[^。\n]*/i);
179
+ findings.push({
180
+ category: "model_design",
181
+ description: match ? match[0].trim() : "Data model design may be incomplete",
182
+ });
183
+ }
184
+
185
+ // ── Layer separation / 层级分离 violations ────────────────────────────
186
+ if (
187
+ /层级.*违反|layer.*violation|business logic.*controller|controller.*service.*混|分层.*问题/i.test(pass1Text)
188
+ ) {
189
+ const match = pass1Text.match(/[^。\n]*(?:层级.*违反|layer.*violation|business logic.*controller|分层.*问题)[^。\n]*/i);
190
+ findings.push({
191
+ category: "layer_violation",
192
+ description: match ? match[0].trim() : "Layer separation may be violated in the generated code",
193
+ });
194
+ }
195
+
196
+ return findings;
197
+ }
198
+
199
+ /** Extract the numeric score from a single pass section. */
200
+ function extractPassScore(text: string): number | null {
201
+ const m = text.match(/Score:\s*(\d+(?:\.\d+)?)\s*\/\s*10/i);
202
+ return m ? parseFloat(m[1]) : null;
203
+ }
204
+
205
+ // ─── Loop 2: Spec Amendment Prompt ────────────────────────────────────────────
206
+
207
+ /**
208
+ * Build a prompt asking the AI to produce a minimal spec amendment
209
+ * that addresses the structural findings from the review.
210
+ *
211
+ * The amendment is a targeted addition/correction — NOT a full rewrite.
212
+ */
213
+ export function buildStructuralAmendmentPrompt(
214
+ spec: string,
215
+ findings: StructuralFinding[]
216
+ ): string {
217
+ const findingList = findings
218
+ .map((f, i) => `${i + 1}. [${f.category}] ${f.description}`)
219
+ .join("\n");
220
+
221
+ return `A code review of the feature built from this spec found the following DESIGN-LEVEL issues.
222
+ These are problems in the spec/contract itself, not in the implementation.
223
+
224
+ === Structural Findings ===
225
+ ${findingList}
226
+
227
+ Your task:
228
+ - Revise the spec below to correct the design issues listed above.
229
+ - Do NOT change the feature scope, business logic, or sections unrelated to these findings.
230
+ - Be minimal: only change what is necessary to fix the design issues.
231
+ - Output ONLY the complete revised Markdown spec. No preamble, no explanation.
232
+
233
+ === Current Spec ===
234
+ ${spec}`;
235
+ }
236
+
237
+ // ─── Display Helpers ──────────────────────────────────────────────────────────
238
+
239
+ export function printDslGaps(gaps: DslGap[]): void {
240
+ console.log(chalk.yellow("\n ⚠ DSL Completeness Check — gaps detected:"));
241
+ for (const gap of gaps) {
242
+ console.log(chalk.yellow(` · ${gap.message}`));
243
+ }
244
+ console.log(chalk.gray(" → A targeted spec refinement can fill these gaps before codegen."));
245
+ }
246
+
247
+ export function printStructuralFindings(findings: StructuralFinding[]): void {
248
+ console.log(chalk.yellow("\n ⚠ Review — structural (design-level) issues found:"));
249
+ for (const f of findings) {
250
+ const label = chalk.gray(`[${f.category}]`);
251
+ console.log(` ${label} ${f.description}`);
252
+ }
253
+ console.log(chalk.gray(" → These are contract issues in the Spec/DSL, not just implementation problems."));
254
+ console.log(chalk.gray(" → Fixing the spec now means the next run generates correct code from the start."));
255
+ }