ccqa 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/ccqa.mjs CHANGED
@@ -414,55 +414,20 @@ async function saveSpecFile(featureName, specName, content, cwd) {
414
414
  await writeFile(specPath, content.endsWith("\n") ? content : content + "\n", "utf-8");
415
415
  return specPath;
416
416
  }
417
- /** Absolute path to the single repo-wide `.ccqa/perspectives.yaml`. */
418
- function getPerspectivesPath(cwd) {
419
- return join(getCcqaDir(cwd), PERSPECTIVES_FILE);
420
- }
421
- /**
422
- * Read `.ccqa/perspectives.yaml` raw. Returns null when the file does not
423
- * exist (first-ever generation) so callers can treat it as optional.
424
- */
425
- async function tryReadPerspectives(cwd) {
426
- return readFile(getPerspectivesPath(cwd), "utf-8").catch(() => null);
427
- }
428
- /**
429
- * Write `.ccqa/perspectives.yaml`. Mirrors `saveSpecFile`: ensures the
430
- * directory exists and the content ends in a trailing newline.
431
- */
432
- async function savePerspectives(content, cwd) {
433
- await mkdir(getCcqaDir(cwd), { recursive: true });
434
- const path = getPerspectivesPath(cwd);
435
- await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
436
- return path;
437
- }
438
- /**
439
- * Human-readable Markdown companion to perspectives.yaml. The `.yaml` is the
440
- * machine-readable source of truth; the `.md` is a rendered view for review.
441
- */
442
- function getPerspectivesMarkdownPath(cwd) {
443
- return join(getCcqaDir(cwd), PERSPECTIVES_MD_FILE);
444
- }
445
- async function savePerspectivesMarkdown(content, cwd) {
446
- await mkdir(getCcqaDir(cwd), { recursive: true });
447
- const path = getPerspectivesMarkdownPath(cwd);
448
- await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
449
- return path;
450
- }
451
417
  /**
452
- * Per-category detail view: `.ccqa/features/<feature>/perspectives.md`. The
453
- * root `perspectives.md` is a thin category index that links here; this file
454
- * carries the full per-case tables for one feature. The feature dir already
455
- * exists (it holds the test cases), but `mkdir -p` keeps this safe when called
456
- * in isolation.
418
+ * The perspectives document now lives on the hub only. Earlier versions wrote
419
+ * it into the repo as `.ccqa/perspectives.yaml` + `.ccqa/perspectives.md` +
420
+ * `.ccqa/features/<feature>/perspectives.md`; remove any of those leftovers.
421
+ * Returns the paths that were actually deleted.
457
422
  */
458
- function getFeaturePerspectivesMarkdownPath(featureName, cwd) {
459
- return join(getFeatureDir(featureName, cwd), PERSPECTIVES_MD_FILE);
460
- }
461
- async function saveFeaturePerspectivesMarkdown(featureName, content, cwd) {
462
- await mkdir(getFeatureDir(featureName, cwd), { recursive: true });
463
- const path = getFeaturePerspectivesMarkdownPath(featureName, cwd);
464
- await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
465
- return path;
423
+ async function removeLegacyPerspectivesFiles(cwd) {
424
+ const candidates = [join(getCcqaDir(cwd), PERSPECTIVES_FILE), join(getCcqaDir(cwd), PERSPECTIVES_MD_FILE)];
425
+ const featuresDir = join(getCcqaDir(cwd), "features");
426
+ const featureNames = await readdir(featuresDir).catch(() => []);
427
+ for (const name of featureNames) candidates.push(join(featuresDir, name, PERSPECTIVES_MD_FILE));
428
+ const removed = [];
429
+ for (const path of candidates) if (await unlink(path).then(() => true).catch(() => false)) removed.push(path);
430
+ return removed;
466
431
  }
467
432
  /**
468
433
  * Replace (or insert) the `relatedPaths` key in the spec. Preserves every
@@ -5562,7 +5527,7 @@ async function runLiveSpecs(specs, opts) {
5562
5527
  if (userPromptBundle !== null) meta("prompt", userPromptBundle.loaded.join(" + "));
5563
5528
  const userPromptSuffix = userPromptBundle?.text ?? null;
5564
5529
  const failureAnalysisEnabled = opts.failureAnalysis !== false;
5565
- const driftBySpec = failureAnalysisEnabled && opts.driftAudit !== false ? await runDriftAudit(specs, opts, cwd) : /* @__PURE__ */ new Map();
5530
+ const driftAuditEnabled = failureAnalysisEnabled && opts.driftAudit !== false;
5566
5531
  const auth = failureAnalysisEnabled ? driftAuthAvailable() : {
5567
5532
  ok: false,
5568
5533
  reason: "disabled"
@@ -5597,12 +5562,12 @@ async function runLiveSpecs(specs, opts) {
5597
5562
  row: null
5598
5563
  };
5599
5564
  const row = await buildLiveReportRow(outcome, {
5600
- driftBySpec,
5601
5565
  auth,
5602
5566
  diff,
5603
5567
  baseRef,
5604
5568
  reportDir,
5605
- failureAnalysisEnabled
5569
+ failureAnalysisEnabled,
5570
+ driftAuditEnabled
5606
5571
  }, opts, cwd);
5607
5572
  await opts.report?.upsert(row);
5608
5573
  return {
@@ -5622,13 +5587,13 @@ async function runLiveSpecs(specs, opts) {
5622
5587
  };
5623
5588
  }
5624
5589
  /**
5625
- * Build one spec's report row: the live-run base row plus drift findings and
5626
- * (for a failed spec) the failure-analysis fields. Runs inside the pool worker
5627
- * so the row can be upserted incrementally the moment the spec finishes.
5590
+ * Build one spec's report row: the live-run base row plus (for a failed spec)
5591
+ * the drift audit and failure-analysis fields. Runs inside the pool worker so
5592
+ * the row can be upserted incrementally the moment the spec finishes. The
5593
+ * drift audit only runs for failed specs — passing specs get no driftIssues,
5594
+ * matching the deterministic path.
5628
5595
  */
5629
5596
  async function buildLiveReportRow(r, ctx, opts, cwd) {
5630
- const key = `${r.featureName}/${r.specName}`;
5631
- const driftForSpec = ctx.driftBySpec.get(key) ?? null;
5632
5597
  const base = await liveRunToReportResult({
5633
5598
  featureName: r.featureName,
5634
5599
  specName: r.specName,
@@ -5636,6 +5601,7 @@ async function buildLiveReportRow(r, ctx, opts, cwd) {
5636
5601
  result: r.result,
5637
5602
  reportDir: ctx.reportDir
5638
5603
  });
5604
+ const driftForSpec = ctx.driftAuditEnabled && r.result.status === "failed" ? await runDriftAuditOne(r, opts, cwd) : null;
5639
5605
  const analysis = ctx.failureAnalysisEnabled && r.result.status === "failed" ? await analyzeOneLiveFailure(r, ctx.diff, ctx.baseRef, driftForSpec, ctx.auth, opts, cwd) : void 0;
5640
5606
  return {
5641
5607
  ...base,
@@ -5659,39 +5625,31 @@ function analysisFieldsFor(a, status, failureAnalysisEnabled) {
5659
5625
  return {};
5660
5626
  }
5661
5627
  /**
5662
- * Run `analyzeDrift` against every spec and return a
5663
- * `featureName/specName driftIssues` map. This is a static spec↔code audit,
5664
- * so it takes the spec list directly and runs before execution — each worker
5665
- * then has its spec's drift context in hand for the failure-analysis prompt.
5666
- * Drift findings are advisory — they show in the report (report.json / hub UI)
5667
- * but do not change the live-run exit code.
5628
+ * Run `analyzeDrift` for one failed spec, used as evidence for its
5629
+ * failure-analysis prompt and shown in its report row. Mirrors the
5630
+ * deterministic path (src/run/pipeline.ts), which also scopes the audit to
5631
+ * failed specs only. Drift findings are advisory they never change the
5632
+ * live-run exit code.
5668
5633
  */
5669
- async function runDriftAudit(specs, opts, cwd) {
5670
- const targets = specs.map((s) => ({
5671
- featureName: s.featureName,
5672
- specName: s.specName
5673
- }));
5674
- const out = /* @__PURE__ */ new Map();
5675
- if (targets.length === 0) return out;
5676
- blank();
5677
- info(`drift audit: ${targets.length} spec${targets.length > 1 ? "s" : ""}`);
5678
- const results = await analyzeDrift({
5679
- targets,
5634
+ async function runDriftAuditOne(r, opts, cwd) {
5635
+ const key = `${r.featureName}/${r.specName}`;
5636
+ info(`drift audit: ${key}`);
5637
+ const blocks = await loadAvailableBlocks(cwd);
5638
+ const [result] = await analyzeDrift({
5639
+ targets: [{
5640
+ featureName: r.featureName,
5641
+ specName: r.specName
5642
+ }],
5680
5643
  cwd,
5681
- blocks: await loadAvailableBlocks(cwd),
5644
+ blocks,
5682
5645
  ...opts.model ? { model: opts.model } : {},
5683
- ...opts.language ? { language: opts.language } : {},
5684
- onSpecStart: (t) => info(` checking ${t.featureName}/${t.specName}`)
5646
+ ...opts.language ? { language: opts.language } : {}
5685
5647
  });
5686
- for (const r of results) {
5687
- const key = `${r.target.featureName}/${r.target.specName}`;
5688
- if (r.ok) out.set(key, r.issues.length > 0 ? r.issues : null);
5689
- else {
5690
- warn(`drift audit failed for ${key}: ${r.error}`);
5691
- out.set(key, null);
5692
- }
5648
+ if (!result || !result.ok) {
5649
+ warn(`drift audit failed for ${key}: ${result?.error ?? "no result"}`);
5650
+ return null;
5693
5651
  }
5694
- return out;
5652
+ return result.issues.length > 0 ? result.issues : null;
5695
5653
  }
5696
5654
  /**
5697
5655
  * Resolve `spec.session` names to a single state file to restore, fetching
@@ -11489,543 +11447,247 @@ async function resolveCwdPrefix(cwd) {
11489
11447
  }
11490
11448
  }
11491
11449
  //#endregion
11492
- //#region src/cli/generate.ts
11493
- const AUTO_FIX_MODES = [
11494
- "interactive",
11495
- "auto",
11496
- "skip"
11497
- ];
11498
- function toFixMode(autoFix) {
11499
- switch (autoFix) {
11500
- case "auto": return "auto";
11501
- case "skip": return "non-interactive";
11502
- case "interactive": return "interactive";
11503
- }
11504
- }
11505
- /** Shared `--auto-fix` parser for the record / generate commands. */
11506
- function parseAutoFixFlag(raw) {
11507
- if (AUTO_FIX_MODES.includes(raw)) return raw;
11508
- throw new Error(`--auto-fix must be one of ${AUTO_FIX_MODES.join(" | ")}`);
11509
- }
11450
+ //#region src/prompts/perspectives.ts
11510
11451
  /**
11511
- * The `generate` flow shared by `ccqa generate` and the codegen half of
11512
- * `ccqa record`: resolve the spec's target plugin, load its input (the
11513
- * recording, for input:"recording" targets), and dispatch to the plugin.
11514
- * This layer owns the CLI concerns overwrite confirmation, logging,
11515
- * exit-code policy — while the plugin owns the generation pipeline.
11452
+ * Build the system prompt. By default the descriptive fields follow the
11453
+ * spec's own language (Japanese specs Japanese fields). An explicit
11454
+ * `--language` is applied by the CLI via `languageDirective`, appended to
11455
+ * this prompt, so the language handling lives in one shared place.
11516
11456
  */
11517
- async function runGenerate(featureName, specName, opts) {
11518
- header("generate", `${featureName}/${specName}`);
11519
- const cwd = opts.cwd ?? process.cwd();
11520
- await ensureCcqaDir(cwd);
11521
- const releaseLock = await acquireSpecLock(featureName, specName, "generate", cwd);
11522
- try {
11523
- await runGenerateLocked(featureName, specName, opts, cwd);
11524
- } finally {
11525
- await releaseLock();
11526
- }
11457
+ function buildPerspectivesSystemPrompt() {
11458
+ return `You produce a factual inventory of the E2E test coverage that already exists in a ccqa project.
11459
+
11460
+ Think of it as a QA coverage stock-take: for each existing test case, fill in a few short, neutral descriptive fields derived from its steps. Nothing more.
11461
+
11462
+ ## Hard boundaries (do NOT cross)
11463
+
11464
+ - Do NOT assign severity, importance, priority, or risk. Whether a failure hurts the customer is a human + PdM decision; you are not authoring that here.
11465
+ - Do NOT do gap analysis. Do NOT list untested areas, missing coverage, or things the code has but the tests lack.
11466
+ - Do NOT evaluate whether the feature is good, complete, or correct.
11467
+ - Do NOT propose new test cases.
11468
+ - Do NOT restate the full step-by-step procedure or the per-step expected results — the spec.yaml is the source of truth for those and the inventory links to it.
11469
+ - Do NOT touch status, relatedPaths, feature names, or spec names — the CLI already fixed those.
11470
+
11471
+ ## Fields to write (per spec)
11472
+
11473
+ - \`summary\`: 1–2 sentences, factual and neutral. What the test exercises and what it ultimately asserts, derived from the spec's \`steps\` (\`instruction\` / \`expected\`).
11474
+ - \`startScreen\`: the screen/URL the test first lands on after setup (e.g. "Dashboard (/dashboard)"). Derive from the first non-login \`instruction\`. Omit if genuinely unclear.
11475
+ - \`testCondition\`: the state/precondition the scenario assumes, phrased as a condition (e.g. "Logged in as an admin", "Unauthenticated user"). Omit if none.
11476
+ - \`preconditions\`: array of short setup prerequisites (e.g. which role logs in, required prior state). Derive from \`include: login\` params and the opening steps. Empty/omit if none.
11477
+
11478
+ ## How to write
11479
+
11480
+ - Same language as the spec's title (if titles are Japanese, write these fields in Japanese).
11481
+ - Keep each field short. These are index entries, not the test itself.
11482
+ - You may use Read/Grep/Glob sparingly to clarify domain vocabulary, but the steps are the primary source. Do not over-explore.
11483
+
11484
+ ## Output contract (STRICT)
11485
+
11486
+ Output exactly ONE fenced \`\`\`json code block, and nothing else outside it. No prose before or after.
11487
+
11488
+ Schema:
11489
+
11490
+ \`\`\`json
11491
+ {
11492
+ "summaries": [
11493
+ {
11494
+ "featureName": "<verbatim from input>",
11495
+ "specName": "<verbatim from input>",
11496
+ "summary": "<1–2 sentence factual description of what this test verifies>",
11497
+ "startScreen": "<opening screen/URL, or omit>",
11498
+ "testCondition": "<assumed state phrased as a condition, or omit>",
11499
+ "preconditions": ["<setup prerequisite>", "..."]
11500
+ }
11501
+ ]
11527
11502
  }
11528
- async function runGenerateLocked(featureName, specName, opts, cwd) {
11529
- const specYaml = await readSpecFile(featureName, specName, cwd);
11530
- const spec = parseTestSpec(specYaml);
11531
- const config = await loadProjectConfig(cwd);
11532
- const target = opts.targetOverride !== void 0 ? resolveTargetOverride(spec, opts.targetOverride) : resolveTarget(spec, config);
11533
- meta("target", target.id + (opts.targetOverride !== void 0 ? " (--target override)" : ""));
11534
- const existingOutput = await target.existingOutput?.({
11535
- featureName,
11536
- specName
11537
- }, cwd) ?? null;
11538
- if (existingOutput && !opts.force) {
11539
- if (!await confirmOverwrite(existingOutput)) {
11540
- info("aborted; pass --force to overwrite without prompting");
11541
- return;
11542
- }
11543
- }
11544
- let recording;
11545
- if (target.input === "recording") {
11546
- const { path: recordingPath, actions } = await getRecording(featureName, specName, cwd);
11547
- meta("recording", recordingPath);
11548
- meta("actions", actions.length);
11549
- recording = actions;
11503
+ \`\`\`
11504
+
11505
+ Return one entry per spec given in the input. Echo featureName and specName verbatim so the CLI can match them. \`startScreen\`, \`testCondition\`, and \`preconditions\` are optional — omit a field (or use an empty array for preconditions) when the spec does not express it.
11506
+ `;
11507
+ }
11508
+ function buildPerspectivesPrompt(specs, instruction) {
11509
+ return `## Existing test cases to summarise
11510
+
11511
+ ${specs.map((s) => `### ${s.featureName}/${s.specName}
11512
+ title: ${s.title}
11513
+
11514
+ \`\`\`yaml
11515
+ ${s.specYaml.trimEnd()}
11516
+ \`\`\`
11517
+ `).join("\n")}
11518
+ ${instruction?.trim() ? `## Extra guidance from the user\n\n${instruction.trim()}\n\n` : ""}## Task
11519
+
11520
+ For each test case above, write a 1–2 sentence factual \`summary\` of what it verifies, derived from its steps. Return one entry per spec in the JSON contract. Do not assign severity, do gap analysis, or invent new cases.
11521
+ `;
11522
+ }
11523
+ //#endregion
11524
+ //#region src/cli/draft.ts
11525
+ const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
11526
+ const draftCommand = addLanguageOption(new Command("draft").argument("[feature/spec]", "Optional spec path (e.g. tasks/create-and-complete). If omitted, Claude proposes one from your intent.").description("Interactively draft and refine a spec.yaml with Claude Code").option("--instruction <text>", "Non-interactive single-shot instruction (skips the interactive loop)").option("--apply", "Auto-apply each generated patch without [y/N] confirmation", false)).action(async (specPath, opts) => {
11527
+ await ensureCcqaDir();
11528
+ let featureName;
11529
+ let specName;
11530
+ let prefilledIntent = null;
11531
+ if (specPath) ({featureName, specName} = parseSpecPath(specPath));
11532
+ else {
11533
+ const { naming, intent } = await proposeNaming(opts);
11534
+ featureName = naming.featureName;
11535
+ specName = naming.specName;
11536
+ prefilledIntent = intent;
11550
11537
  }
11551
- await warnStaleBlockArtifacts();
11552
- const targetConfig = config.targets[target.id] ?? TargetConfigSchema.parse({});
11553
- const ctx = {
11554
- spec,
11555
- specYaml,
11556
- featureName,
11557
- specName,
11558
- cwd,
11559
- recording,
11560
- resources: targetConfig.resources,
11561
- conventions: targetConfig.conventions,
11562
- targetConfig,
11563
- language: opts.language,
11564
- model: opts.model,
11565
- hub: opts.hubContext ?? null,
11566
- fix: {
11567
- maxRetries: opts.maxRetries,
11568
- mode: opts.fixMode,
11569
- useSnapshot: opts.useSnapshot
11538
+ await runDraft(featureName, specName, opts, prefilledIntent);
11539
+ });
11540
+ async function runDraft(featureName, specName, opts, prefilledIntent) {
11541
+ header("draft", `${featureName}/${specName}`);
11542
+ const ja = useJapanesePrompts(opts.language);
11543
+ const oneShot = opts.instruction !== void 0;
11544
+ let useIntentOnce = prefilledIntent !== null && !oneShot;
11545
+ while (true) {
11546
+ const existing = await tryReadSpecFile(featureName, specName);
11547
+ const isFirstRun = existing === null;
11548
+ let userInput;
11549
+ if (oneShot) userInput = opts.instruction ?? "";
11550
+ else if (useIntentOnce && isFirstRun) {
11551
+ userInput = prefilledIntent ?? "";
11552
+ useIntentOnce = false;
11553
+ } else userInput = await prompt(isFirstRun ? ja ? "何をテストしたいですか? > " : "What do you want to test? > " : ja ? "どのように修正しますか? (空欄で再検証) > " : "How would you like to refine? (empty = re-validate) > ");
11554
+ if (isFirstRun && !userInput.trim()) {
11555
+ error("intent required for the first draft (no spec exists yet)");
11556
+ process.exit(1);
11557
+ }
11558
+ const turnResult = await runOneTurn({
11559
+ featureName,
11560
+ specName,
11561
+ existing,
11562
+ userInput: userInput.trim(),
11563
+ autoApply: opts.apply === true,
11564
+ language: opts.language
11565
+ });
11566
+ if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
11567
+ blank();
11568
+ if (/^y/i.test(await prompt(ja ? "このドラフトは完了ですか? [y/N] " : "Are you done with this draft? [y/N] "))) {
11569
+ info("draft session complete.");
11570
+ hint(`run 'ccqa trace ${featureName}/${specName}' to record actions`);
11571
+ process.exit(0);
11570
11572
  }
11571
- };
11572
- if (!(await target.generate(ctx)).passed) {
11573
- warn("auto-fix exhausted; test still failing");
11574
- process.exit(1);
11575
11573
  }
11576
- hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
11577
11574
  }
11578
- async function confirmOverwrite(path) {
11579
- if (!process.stdin.isTTY) {
11580
- warn(`${path} exists and stdin is not a TTY; refusing to overwrite. Pass --force to allow.`);
11581
- return false;
11582
- }
11583
- const rl = createInterface({
11584
- input: process.stdin,
11585
- output: process.stdout
11575
+ async function runOneTurn(input) {
11576
+ const { featureName, specName, existing, userInput, autoApply, language } = input;
11577
+ const isFirstRun = existing === null;
11578
+ const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks()) + languageDirective(language);
11579
+ const userPrompt = buildDraftPrompt({
11580
+ mode: isFirstRun ? "create" : "refine",
11581
+ existing: existing ?? "",
11582
+ userInput
11586
11583
  });
11587
- try {
11588
- process.stdout.write("\n");
11589
- process.stdout.write(`[warn] ${path} already exists.\n`);
11590
- process.stdout.write(`[warn] generate will regenerate it and any manual edits will be lost.\n`);
11591
- const norm = (await new Promise((res) => rl.question("Overwrite? [y/N] ", res))).trim().toLowerCase();
11592
- return norm === "y" || norm === "yes";
11593
- } finally {
11594
- rl.close();
11595
- }
11596
- }
11597
- const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate test code from a spec via its target plugin. Recording-backed targets compile the existing ir.json (run `ccqa record` first); spec-input targets generate directly from the spec.").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--target <id>", "Generate through this target instead of the spec's own — e.g. emit a Playwright spec from an agent-browser recording. The spec's `target:` stays the default for `ccqa run`.").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N), 'auto' (apply without prompt, for CI), 'skip' (never prompt, only apply high-confidence fixes).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--force", "Overwrite previously generated test code without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (specPath, opts) => {
11598
- const { featureName, specName } = parseSpecPath(specPath);
11599
- const language = opts.language ?? "auto";
11600
- const cwd = resolveCwd(opts.cwd);
11601
- const project = opts.profile !== void 0 ? resolveProject(opts) : void 0;
11602
- if (opts.profile !== void 0) await applyProfileFromOption({
11603
- profile: opts.profile,
11604
- project,
11605
- cwd,
11606
- hubUrl: opts.hubUrl,
11607
- hubToken: opts.hubToken,
11608
- hubHeader: opts.hubHeader
11609
- });
11610
- else await applyProfileFromOption({
11611
- profile: void 0,
11612
- project: "",
11613
- cwd
11614
- });
11615
- const hubClient = resolveHubClient({
11616
- hubUrl: opts.hubUrl,
11617
- hubToken: opts.hubToken,
11618
- hubHeader: opts.hubHeader
11584
+ info(isFirstRun ? "Reading codebase and drafting spec..." : "Re-validating spec against codebase...");
11585
+ const toolCounts = {};
11586
+ const startedAt = Date.now();
11587
+ const { result, isError } = await invokeClaudeStreaming({
11588
+ prompt: userPrompt,
11589
+ systemPrompt,
11590
+ allowedTools: [
11591
+ "Read",
11592
+ "Grep",
11593
+ "Glob"
11594
+ ],
11595
+ silenceBashLog: true
11596
+ }, (msg) => {
11597
+ if (msg.type !== "assistant") return;
11598
+ for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
11619
11599
  });
11620
- const hubContext = hubClient && project ? {
11621
- hub: hubClient,
11622
- project
11623
- } : null;
11600
+ printToolSummary(toolCounts, Date.now() - startedAt);
11601
+ if (isError) {
11602
+ error("Claude returned an error result");
11603
+ return {
11604
+ hasError: true,
11605
+ applied: false
11606
+ };
11607
+ }
11608
+ const json = extractJsonBlock(result);
11609
+ if (!json) {
11610
+ error("Claude did not return a json block");
11611
+ warn(`raw tail: ${truncate(result, 200)}`);
11612
+ return {
11613
+ hasError: true,
11614
+ applied: false
11615
+ };
11616
+ }
11617
+ let report;
11624
11618
  try {
11625
- await runGenerate(featureName, specName, {
11626
- maxRetries: parseInt(opts.maxRetries ?? "3", 10),
11627
- fixMode: toFixMode(opts.autoFix ?? "interactive"),
11628
- force: opts.force ?? false,
11629
- useSnapshot: opts.snapshot !== false,
11630
- language,
11631
- model: opts.model,
11632
- targetOverride: opts.target,
11633
- cwd,
11634
- hubContext
11635
- });
11619
+ report = DraftReportSchema.parse(JSON.parse(json));
11636
11620
  } catch (e) {
11637
- if (e instanceof SpecLockedError) {
11638
- error(e.message);
11639
- process.exit(2);
11640
- }
11641
- throw e;
11621
+ error(`failed to parse draft report: ${e.message}`);
11622
+ return {
11623
+ hasError: true,
11624
+ applied: false
11625
+ };
11642
11626
  }
11643
- });
11644
- //#endregion
11645
- //#region src/cli/record.ts
11646
- const VALIDATION_MODES = ["lenient", "strict"];
11647
- const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("record").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Record a deterministic test from a spec: run agent-browser to collect actions (trace), then generate test.spec.ts with auto-fix retries (generate). After recording, `ccqa run <feature/spec>` replays it under vitest (deterministic specs only — live specs do not need recording).").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--validation-mode <mode>", "Post-trace validation behaviour: 'lenient' (default) tags failing actions; 'strict' drops them.", (raw) => {
11648
- if (VALIDATION_MODES.includes(raw)) return raw;
11649
- throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
11650
- }, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N), 'auto' (apply without prompt, for CI), 'skip' (never prompt, only apply high-confidence fixes).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--force", "Overwrite an existing test.spec.ts without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--skip-trace", "Skip the trace step and run codegen against an existing ir.json").option("--skip-codegen", "Run only the trace step (do not generate test.spec.ts)").option("--update-agent-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (specPath, opts) => {
11651
- const { featureName, specName } = parseSpecPath(specPath);
11652
- const language = opts.language ?? "auto";
11653
- if (opts.skipTrace && opts.skipCodegen) {
11654
- error("--skip-trace and --skip-codegen cannot be combined; nothing would run");
11655
- process.exit(2);
11627
+ const hasError = printReviewBlock(report.issues);
11628
+ const original = existing ?? "";
11629
+ if (!report.patch || report.patch === original) {
11630
+ blank();
11631
+ info("no changes proposed.");
11632
+ return {
11633
+ hasError,
11634
+ applied: false
11635
+ };
11656
11636
  }
11657
- const cwdForProfile = resolveCwd(opts.cwd);
11658
- const target = resolveTarget(parseTestSpec(await readSpecFile(featureName, specName, cwdForProfile)), await loadProjectConfig(cwdForProfile));
11659
- if (target.input === "spec") {
11660
- error(`target "${target.id}" does not use a browser recording — run 'ccqa generate ${featureName}/${specName}' instead`);
11661
- process.exit(2);
11637
+ blank();
11638
+ info("--- proposed changes ---");
11639
+ printUnifiedDiff(original, report.patch);
11640
+ blank();
11641
+ if (!(autoApply ? true : /^y/i.test(await prompt(useJapanesePrompts(language) ? "このパッチを適用しますか? [y/N] " : "Apply this patch? [y/N] ")))) {
11642
+ info("aborted — no changes applied.");
11643
+ return {
11644
+ hasError,
11645
+ applied: false
11646
+ };
11662
11647
  }
11663
- const project = opts.profile !== void 0 ? resolveProject(opts) : void 0;
11664
- if (opts.profile !== void 0) await applyProfileFromOption({
11665
- profile: opts.profile,
11666
- project,
11667
- cwd: cwdForProfile,
11668
- hubUrl: opts.hubUrl,
11669
- hubToken: opts.hubToken,
11670
- hubHeader: opts.hubHeader
11671
- });
11672
- else await applyProfileFromOption({
11673
- profile: void 0,
11674
- project: "",
11675
- cwd: cwdForProfile
11676
- });
11677
- const hubClientForTrace = resolveHubClient({
11678
- hubUrl: opts.hubUrl,
11679
- hubToken: opts.hubToken,
11680
- hubHeader: opts.hubHeader
11648
+ try {
11649
+ parseTestSpec(report.patch);
11650
+ } catch (e) {
11651
+ error(`refused to apply: patch failed validation (${e.message})`);
11652
+ return {
11653
+ hasError: true,
11654
+ applied: false
11655
+ };
11656
+ }
11657
+ meta("saved", await saveSpecFile(featureName, specName, report.patch));
11658
+ return {
11659
+ hasError,
11660
+ applied: true
11661
+ };
11662
+ }
11663
+ async function prompt(question) {
11664
+ const rl = createInterface$1({
11665
+ input: process.stdin,
11666
+ output: process.stdout
11681
11667
  });
11682
- const hubContext = hubClientForTrace && project ? {
11683
- hub: hubClientForTrace,
11684
- project
11685
- } : null;
11686
- const releaseLock = await acquireSpecLock(featureName, specName, "record", cwdForProfile).catch((e) => {
11687
- if (e instanceof SpecLockedError) {
11688
- error(e.message);
11689
- process.exit(2);
11690
- }
11691
- throw e;
11668
+ rl.on("SIGINT", () => {
11669
+ rl.close();
11670
+ process.exit(130);
11692
11671
  });
11693
- let traceResult = null;
11694
11672
  try {
11695
- if (!opts.skipTrace) {
11696
- traceResult = await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient", language, {
11697
- cwd: cwdForProfile,
11698
- hubContext
11699
- });
11700
- blank();
11701
- }
11702
- if (!opts.skipCodegen) await runGenerate(featureName, specName, {
11703
- maxRetries: parseInt(opts.maxRetries ?? "3", 10),
11704
- fixMode: toFixMode(opts.autoFix ?? "interactive"),
11705
- force: opts.force ?? false,
11706
- useSnapshot: opts.snapshot !== false,
11707
- language,
11708
- model: opts.model,
11709
- cwd: cwdForProfile,
11710
- hubContext
11711
- });
11673
+ return (await rl.question(question)).trim();
11712
11674
  } finally {
11713
- await releaseLock();
11714
- }
11715
- if (opts.updateAgentPrompt) if (traceResult === null) warn("--update-agent-prompt is ignored when --skip-trace is set (no run summary available)");
11716
- else {
11717
- blank();
11718
- await updateAgentPrompt({
11719
- mode: "record",
11720
- runSummary: buildRecordRunSummary(featureName, specName, traceResult),
11721
- hubContext,
11722
- ...opts.model ? { model: opts.model } : {},
11723
- ...language ? { language } : {}
11724
- });
11675
+ rl.close();
11725
11676
  }
11726
- });
11727
- /**
11728
- * Compact summary of the trace pass for the record agent-prompt refresh.
11729
- * Steps are reconstructed from the trace's status-line protocol (STEP_START
11730
- * gives the title, STEP_DONE / ASSERTION_FAILED / STEP_SKIPPED the outcome),
11731
- * and each step carries its per-action observations plus the **concrete kept
11732
- * commands** the selectors that actually survived scrub / dedup /
11733
- * validation. The record playbook is told to record canonical selectors, so
11734
- * it needs those exact tokens; the prose alone doesn't carry them. The
11735
- * header's kept/recorded totals flag how much the run thrashed through
11736
- * selectors overall.
11737
- */
11738
- function buildRecordRunSummary(featureName, specName, t) {
11739
- const header = `## ${featureName}/${specName} — ${t.status}\nActions: ${t.actionsKept} kept / ${t.actionsRecorded} recorded`;
11740
- const steps = collectStepSummaries(t.statusLines);
11741
- if (steps.length === 0) return `${header}\n\n(no step status lines recorded)`;
11742
- const commandsByStep = groupCommandsByStep(t.actions);
11743
- const observationsByStep = groupObservationsByStep(t.actions);
11744
- const unstableByStep = countUnstableByStep(t.actions);
11745
- return `${header}\n\n${steps.map((s) => {
11746
- const cmds = commandsByStep.get(s.stepId) ?? [];
11747
- const observations = observationsByStep.get(s.stepId) ?? [];
11748
- const churn = t.churnByStep.get(s.stepId);
11749
- const dropped = churn ? churn.recorded - churn.kept : 0;
11750
- const redundant = churn?.redundant ?? 0;
11751
- const unstable = unstableByStep.get(s.stepId) ?? 0;
11752
- return [
11753
- `### ${s.stepId} — ${oneLineSummary(s.title)} (${s.status})`,
11754
- ...s.detail ? [`- result: ${oneLineSummary(s.detail)}`] : [],
11755
- ...observations.length > 0 ? [`- observations: ${observations.map(oneLineSummary).join(" ; ")}`] : [],
11756
- ...dropped > 0 ? [`- churn: ${churn.recorded} attempts → ${churn.kept} kept (${dropped} dropped)`] : [],
11757
- ...redundant > 0 ? [`- redundant: ${redundant} field(s) entered via 2+ selectors (kept both — record one canonical selector)`] : [],
11758
- ...unstable > 0 ? [`- replay-unstable: ${unstable} kept command(s) marked [unstable] — do NOT record these as canonical; prefer a more stable locator even if it costs one more probe`] : [],
11759
- ...cmds.length > 0 ? [`- kept commands: ${cmds.join(" ; ")}`] : []
11760
- ].join("\n");
11761
- }).join("\n\n")}`;
11677
+ }
11678
+ /** Aggregated tool-call counts shown after each Claude turn. */
11679
+ function formatToolSummary(counts, elapsedMs) {
11680
+ const entries = Object.entries(counts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).map(([name, n]) => `${n} ${name}`);
11681
+ return ` ✓ ${entries.length === 0 ? "no tool calls" : entries.join(", ")} (${(elapsedMs / 1e3).toFixed(1)}s)`;
11682
+ }
11683
+ function printToolSummary(counts, elapsedMs) {
11684
+ process.stdout.write(`${formatToolSummary(counts, elapsedMs)}\n`);
11762
11685
  }
11763
11686
  /**
11764
- * Fold the ordered status lines into one entry per step: STEP_START opens
11765
- * the entry (its detail is the step title); the terminal line sets the
11766
- * outcome. A terminal line for a step that never emitted STEP_START still
11767
- * gets an entry so its outcome isn't silently lost.
11768
- */
11769
- function collectStepSummaries(lines) {
11770
- const byId = /* @__PURE__ */ new Map();
11771
- const ordered = [];
11772
- const ensure = (stepId, title) => {
11773
- let entry = byId.get(stepId);
11774
- if (!entry) {
11775
- entry = {
11776
- stepId,
11777
- title,
11778
- status: "NO_STATUS"
11779
- };
11780
- byId.set(stepId, entry);
11781
- ordered.push(entry);
11782
- }
11783
- return entry;
11784
- };
11785
- for (const line of lines) {
11786
- if (!line.stepId || line.type === "RUN_COMPLETED") continue;
11787
- if (line.type === "STEP_START") {
11788
- ensure(line.stepId, line.detail);
11789
- continue;
11790
- }
11791
- const entry = ensure(line.stepId, "(untitled)");
11792
- entry.status = line.type === "STEP_DONE" ? "DONE" : line.type === "ASSERTION_FAILED" ? "FAILED" : "SKIPPED";
11793
- if (line.detail) entry.detail = line.detail;
11794
- }
11795
- return ordered;
11796
- }
11797
- /** Group each kept action's `action selector` form under its stepId. */
11798
- function groupCommandsByStep(actions) {
11799
- const byStep = /* @__PURE__ */ new Map();
11800
- for (const a of actions) {
11801
- if (!a.stepId) continue;
11802
- const list = byStep.get(a.stepId) ?? [];
11803
- list.push(formatRecordedAction(a));
11804
- byStep.set(a.stepId, list);
11805
- }
11806
- return byStep;
11807
- }
11808
- /**
11809
- * Group per-action observations (snapshot / assert prose) under their stepId
11810
- * — the trace's own record of what it verified at each step.
11811
- */
11812
- function groupObservationsByStep(actions) {
11813
- const byStep = /* @__PURE__ */ new Map();
11814
- for (const a of actions) {
11815
- if (!a.stepId || !a.observation) continue;
11816
- const list = byStep.get(a.stepId) ?? [];
11817
- list.push(a.observation);
11818
- byStep.set(a.stepId, list);
11819
- }
11820
- return byStep;
11821
- }
11822
- /** Per-step count of kept-but-replay-unstable actions (the flaky selectors). */
11823
- function countUnstableByStep(actions) {
11824
- const byStep = /* @__PURE__ */ new Map();
11825
- for (const a of actions) {
11826
- if (!a.stepId || !a.replayUnstable) continue;
11827
- byStep.set(a.stepId, (byStep.get(a.stepId) ?? 0) + 1);
11828
- }
11829
- return byStep;
11830
- }
11831
- /**
11832
- * One kept action as `action selector` (with the fill value or assert
11833
- * type when present) — the canonical form the record playbook should reuse.
11834
- * Unlike the live summary this keeps selectors verbatim: record learns
11835
- * concrete per-spec selectors, so masking them would defeat the purpose.
11836
- *
11837
- * A replay-unstable action (kept in lenient mode but flagged because its
11838
- * selector wasn't present on a fresh replay) is tagged `[unstable](<reason>)`
11839
- * so the learner does NOT record its selector as canonical — the reason is the
11840
- * teacher signal ("not present within Nms" = timing-fragile, not stable).
11841
- */
11842
- function formatRecordedAction(a) {
11843
- const parts = [a.action];
11844
- if (a.index !== void 0) parts.push(String(a.index));
11845
- const anchor = a.locator ? formatLocator(a.locator) : a.label;
11846
- if (anchor) parts.push(anchor);
11847
- if (a.value) parts.push(`= ${a.value}`);
11848
- if (a.assert) parts.push(`(${a.assert})`);
11849
- const line = oneLineSummary(parts.join(" "));
11850
- return a.replayUnstable ? `${line} [unstable](${oneLineSummary(a.replayReason ?? "no reason")})` : line;
11851
- }
11852
- /** Verbatim locator form: raw selector for css, `by=value` for semantic ones. */
11853
- function formatLocator(locator) {
11854
- const name = locator.by === "role" && locator.name ? ` name=${locator.name}` : "";
11855
- return locator.by === "css" ? locator.value : `${locator.by}=${locator.value}${name}`;
11856
- }
11857
- function oneLineSummary(s) {
11858
- const flat = s.replace(/\s+/g, " ").trim();
11859
- return flat.length > 240 ? flat.slice(0, 240) + "…" : flat || "(none)";
11860
- }
11861
- //#endregion
11862
- //#region src/cli/draft.ts
11863
- const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
11864
- const draftCommand = addLanguageOption(new Command("draft").argument("[feature/spec]", "Optional spec path (e.g. tasks/create-and-complete). If omitted, Claude proposes one from your intent.").description("Interactively draft and refine a spec.yaml with Claude Code").option("--instruction <text>", "Non-interactive single-shot instruction (skips the interactive loop)").option("--apply", "Auto-apply each generated patch without [y/N] confirmation", false)).action(async (specPath, opts) => {
11865
- await ensureCcqaDir();
11866
- let featureName;
11867
- let specName;
11868
- let prefilledIntent = null;
11869
- if (specPath) ({featureName, specName} = parseSpecPath(specPath));
11870
- else {
11871
- const { naming, intent } = await proposeNaming(opts);
11872
- featureName = naming.featureName;
11873
- specName = naming.specName;
11874
- prefilledIntent = intent;
11875
- }
11876
- await runDraft(featureName, specName, opts, prefilledIntent);
11877
- });
11878
- async function runDraft(featureName, specName, opts, prefilledIntent) {
11879
- header("draft", `${featureName}/${specName}`);
11880
- const ja = useJapanesePrompts(opts.language);
11881
- const oneShot = opts.instruction !== void 0;
11882
- let useIntentOnce = prefilledIntent !== null && !oneShot;
11883
- while (true) {
11884
- const existing = await tryReadSpecFile(featureName, specName);
11885
- const isFirstRun = existing === null;
11886
- let userInput;
11887
- if (oneShot) userInput = opts.instruction ?? "";
11888
- else if (useIntentOnce && isFirstRun) {
11889
- userInput = prefilledIntent ?? "";
11890
- useIntentOnce = false;
11891
- } else userInput = await prompt(isFirstRun ? ja ? "何をテストしたいですか? > " : "What do you want to test? > " : ja ? "どのように修正しますか? (空欄で再検証) > " : "How would you like to refine? (empty = re-validate) > ");
11892
- if (isFirstRun && !userInput.trim()) {
11893
- error("intent required for the first draft (no spec exists yet)");
11894
- process.exit(1);
11895
- }
11896
- const turnResult = await runOneTurn({
11897
- featureName,
11898
- specName,
11899
- existing,
11900
- userInput: userInput.trim(),
11901
- autoApply: opts.apply === true,
11902
- language: opts.language
11903
- });
11904
- if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
11905
- blank();
11906
- if (/^y/i.test(await prompt(ja ? "このドラフトは完了ですか? [y/N] " : "Are you done with this draft? [y/N] "))) {
11907
- info("draft session complete.");
11908
- hint(`run 'ccqa trace ${featureName}/${specName}' to record actions`);
11909
- process.exit(0);
11910
- }
11911
- }
11912
- }
11913
- async function runOneTurn(input) {
11914
- const { featureName, specName, existing, userInput, autoApply, language } = input;
11915
- const isFirstRun = existing === null;
11916
- const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks()) + languageDirective(language);
11917
- const userPrompt = buildDraftPrompt({
11918
- mode: isFirstRun ? "create" : "refine",
11919
- existing: existing ?? "",
11920
- userInput
11921
- });
11922
- info(isFirstRun ? "Reading codebase and drafting spec..." : "Re-validating spec against codebase...");
11923
- const toolCounts = {};
11924
- const startedAt = Date.now();
11925
- const { result, isError } = await invokeClaudeStreaming({
11926
- prompt: userPrompt,
11927
- systemPrompt,
11928
- allowedTools: [
11929
- "Read",
11930
- "Grep",
11931
- "Glob"
11932
- ],
11933
- silenceBashLog: true
11934
- }, (msg) => {
11935
- if (msg.type !== "assistant") return;
11936
- for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
11937
- });
11938
- printToolSummary(toolCounts, Date.now() - startedAt);
11939
- if (isError) {
11940
- error("Claude returned an error result");
11941
- return {
11942
- hasError: true,
11943
- applied: false
11944
- };
11945
- }
11946
- const json = extractJsonBlock(result);
11947
- if (!json) {
11948
- error("Claude did not return a json block");
11949
- warn(`raw tail: ${truncate(result, 200)}`);
11950
- return {
11951
- hasError: true,
11952
- applied: false
11953
- };
11954
- }
11955
- let report;
11956
- try {
11957
- report = DraftReportSchema.parse(JSON.parse(json));
11958
- } catch (e) {
11959
- error(`failed to parse draft report: ${e.message}`);
11960
- return {
11961
- hasError: true,
11962
- applied: false
11963
- };
11964
- }
11965
- const hasError = printReviewBlock(report.issues);
11966
- const original = existing ?? "";
11967
- if (!report.patch || report.patch === original) {
11968
- blank();
11969
- info("no changes proposed.");
11970
- return {
11971
- hasError,
11972
- applied: false
11973
- };
11974
- }
11975
- blank();
11976
- info("--- proposed changes ---");
11977
- printUnifiedDiff(original, report.patch);
11978
- blank();
11979
- if (!(autoApply ? true : /^y/i.test(await prompt(useJapanesePrompts(language) ? "このパッチを適用しますか? [y/N] " : "Apply this patch? [y/N] ")))) {
11980
- info("aborted — no changes applied.");
11981
- return {
11982
- hasError,
11983
- applied: false
11984
- };
11985
- }
11986
- try {
11987
- parseTestSpec(report.patch);
11988
- } catch (e) {
11989
- error(`refused to apply: patch failed validation (${e.message})`);
11990
- return {
11991
- hasError: true,
11992
- applied: false
11993
- };
11994
- }
11995
- meta("saved", await saveSpecFile(featureName, specName, report.patch));
11996
- return {
11997
- hasError,
11998
- applied: true
11999
- };
12000
- }
12001
- async function prompt(question) {
12002
- const rl = createInterface$1({
12003
- input: process.stdin,
12004
- output: process.stdout
12005
- });
12006
- rl.on("SIGINT", () => {
12007
- rl.close();
12008
- process.exit(130);
12009
- });
12010
- try {
12011
- return (await rl.question(question)).trim();
12012
- } finally {
12013
- rl.close();
12014
- }
12015
- }
12016
- /** Aggregated tool-call counts shown after each Claude turn. */
12017
- function formatToolSummary(counts, elapsedMs) {
12018
- const entries = Object.entries(counts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).map(([name, n]) => `${n} ${name}`);
12019
- return ` ✓ ${entries.length === 0 ? "no tool calls" : entries.join(", ")} (${(elapsedMs / 1e3).toFixed(1)}s)`;
12020
- }
12021
- function printToolSummary(counts, elapsedMs) {
12022
- process.stdout.write(`${formatToolSummary(counts, elapsedMs)}\n`);
12023
- }
12024
- /**
12025
- * Renders the review report as a visually separated block, grouped by
12026
- * severity. ERROR and WARN findings get full detail; OK findings collapse
12027
- * to a one-line summary of category names. Returns whether any ERROR
12028
- * severity was emitted.
11687
+ * Renders the review report as a visually separated block, grouped by
11688
+ * severity. ERROR and WARN findings get full detail; OK findings collapse
11689
+ * to a one-line summary of category names. Returns whether any ERROR
11690
+ * severity was emitted.
12029
11691
  */
12030
11692
  function printReviewBlock(issues) {
12031
11693
  const RULE = "─".repeat(67);
@@ -12228,1013 +11890,1361 @@ function truncate(s, n) {
12228
11890
  return s.slice(s.length - n);
12229
11891
  }
12230
11892
  //#endregion
12231
- //#region src/drift/format.ts
12232
- /**
12233
- * Render drift results as a string. The CLI commands and the `run` failure
12234
- * hook are the only callers; both want the formatted output returned so
12235
- * they can prefix / interleave / pipe it as needed.
11893
+ //#region src/cli/perspectives.ts
11894
+ const perspectivesCommand = addHubOptions(addLanguageOption(new Command("perspectives").description("Generate/update the project's perspectives document on the hub — a factual inventory of existing test coverage (no severity, no gap analysis)").option("--instruction <text>", "Hint to steer how summaries are written").option("--apply", "Auto-apply without [y/N] confirmation", false).option("--check", "Verify the hub document still matches the local specs (mechanical fields only) and exit 1 when it is stale. No Claude calls — cheap enough for CI.", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID").option("--project <name>", "Hub project to store the document under (default: cwd directory name)"))).action(withHubErrors(async (opts) => {
11895
+ if (opts.check) await runPerspectivesCheck(opts);
11896
+ else await runPerspectives(opts);
11897
+ }));
11898
+ /**
11899
+ * `--check`: compare the hub document against a freshly-built local skeleton
11900
+ * on the CLI-owned mechanical fields only (the spec set, titles,
11901
+ * relatedPaths, status). Claude-authored descriptive fields and the human
11902
+ * note are deliberately not compared — they are not deterministic, so they
11903
+ * can't signal staleness. Exit 1 on any mismatch; this is the CI gate for
11904
+ * "someone changed the specs without the inventory catching up".
12236
11905
  */
12237
- function renderDrift(results, format, cwd) {
12238
- if (format === "json") return renderJson(results);
12239
- if (format === "github") return renderGithub(results, cwd);
12240
- return renderText(results);
12241
- }
12242
- const HEAVY_RULE = "═".repeat(72);
12243
- function renderText(results) {
12244
- const out = [];
12245
- for (const r of results) {
12246
- out.push("");
12247
- const heading = `══ ${r.target.featureName}/${r.target.specName} `;
12248
- const tail = "═".repeat(Math.max(3, 72 - heading.length));
12249
- out.push(`${heading}${tail}`);
12250
- if (r.error) {
12251
- out.push(` ERROR ${r.error}`);
12252
- continue;
12253
- }
12254
- const errors = r.issues.filter((i) => i.severity === "ERROR");
12255
- const warnings = r.issues.filter((i) => i.severity === "WARN");
12256
- const passed = r.issues.filter((i) => i.severity === "OK");
12257
- if (errors.length === 0 && warnings.length === 0) {
12258
- const label = passed.length === 1 ? "check" : "checks";
12259
- const detail = passed.length > 0 ? `all ${passed.length} ${label} passed` : "no issues";
12260
- out.push(` ✓ ${detail}`);
12261
- continue;
12262
- }
12263
- for (const issue of errors) appendFinding(out, "ERROR", issue);
12264
- for (const issue of warnings) appendFinding(out, "WARN", issue);
12265
- if (passed.length > 0) {
12266
- const names = passed.map((i) => DRAFT_CATEGORY_LABEL[i.category]).join(", ");
12267
- out.push("");
12268
- out.push(` ✓ passed (${passed.length}): ${names}`);
11906
+ async function runPerspectivesCheck(opts) {
11907
+ const hub = requireHubOrExit(opts);
11908
+ const project = resolveProject(opts);
11909
+ header("perspectives", `check (project: ${project})`);
11910
+ const skeleton = await buildSkeleton(await listFeatureTree());
11911
+ const localCount = skeleton.reduce((n, f) => n + f.specs.length, 0);
11912
+ const existingDoc = await hub.getPerspectives(project);
11913
+ if (existingDoc === null) {
11914
+ if (localCount === 0) {
11915
+ info("no local test cases and no hub document — nothing to check.");
11916
+ return;
12269
11917
  }
11918
+ error(`no perspectives document on the hub for project "${project}" — run \`ccqa perspectives\` to create it`);
11919
+ process.exit(1);
12270
11920
  }
12271
- out.push("");
12272
- out.push(HEAVY_RULE);
12273
- const totals = summarize(results);
12274
- out.push(` specs ${results.length} (${totals.errored} errored)`);
12275
- out.push(` findings ${totals.error} error, ${totals.warn} warn, ${totals.ok} ok`);
12276
- out.push("");
12277
- return out.join("\n");
12278
- }
12279
- function appendFinding(out, level, issue) {
12280
- const stepPart = issue.stepId ? ` ${issue.stepId}` : "";
12281
- out.push("");
12282
- out.push(` ${level} ${DRAFT_CATEGORY_LABEL[issue.category]}${stepPart}`);
12283
- out.push(` ${issue.message}`);
12284
- if (issue.detail) out.push(` └ ${issue.detail.replace(/\n/g, "\n ")}`);
12285
- }
12286
- function renderJson(results) {
12287
- const payload = { specs: results.map((r) => ({
12288
- feature: r.target.featureName,
12289
- spec: r.target.specName,
12290
- ok: r.ok,
12291
- ...r.error ? { error: r.error } : {},
12292
- issues: r.issues.map((i) => ({
12293
- severity: i.severity,
12294
- category: i.category,
12295
- stepId: i.stepId,
12296
- message: i.message,
12297
- ...i.detail ? { detail: i.detail } : {}
12298
- }))
12299
- })) };
12300
- return `${JSON.stringify(payload, null, 2)}\n`;
11921
+ const parsed = PerspectivesSchema.safeParse(existingDoc);
11922
+ if (!parsed.success) {
11923
+ error("the hub document does not match the perspectives schema — run `ccqa perspectives` to regenerate it");
11924
+ process.exit(1);
11925
+ }
11926
+ info(`checking ${localCount} local test case(s) against the hub document...`);
11927
+ const issues = comparePerspectivesSkeleton(skeleton, parsed.data);
11928
+ if (issues.length === 0) {
11929
+ blank();
11930
+ info(`perspectives are up to date (${localCount} case(s)).`);
11931
+ return;
11932
+ }
11933
+ blank();
11934
+ for (const issue of issues) error(issue);
11935
+ blank();
11936
+ error(`perspectives are stale (${issues.length} issue(s)) — run \`ccqa perspectives\` to regenerate`);
11937
+ process.exit(1);
12301
11938
  }
12302
- function renderGithub(results, cwd) {
12303
- const repoRoot = process.env["GITHUB_WORKSPACE"] ?? process.cwd();
12304
- const lines = [];
12305
- for (const r of results) {
12306
- const file = githubRelPath(cwd, repoRoot, r.target.featureName, r.target.specName);
12307
- if (r.error) {
12308
- lines.push(`::error file=${file}::${escapeGhMessage(r.error)}`);
11939
+ /**
11940
+ * Mechanical-field comparison behind `--check`. Returns one human-readable
11941
+ * line per out-of-sync spec (empty when in sync). Exported for unit testing.
11942
+ */
11943
+ function comparePerspectivesSkeleton(local, remote) {
11944
+ const remoteMap = /* @__PURE__ */ new Map();
11945
+ for (const feature of remote.features) for (const spec of feature.specs) remoteMap.set(noteKey(feature.featureName, spec.specName), spec);
11946
+ const issues = [];
11947
+ const seen = /* @__PURE__ */ new Set();
11948
+ for (const feature of local) for (const spec of feature.specs) {
11949
+ const key = noteKey(feature.featureName, spec.specName);
11950
+ seen.add(key);
11951
+ const remoteSpec = remoteMap.get(key);
11952
+ if (!remoteSpec) {
11953
+ issues.push(`${key}: not in the hub document`);
12309
11954
  continue;
12310
11955
  }
12311
- for (const issue of r.issues) {
12312
- if (issue.severity === "OK") continue;
12313
- const level = issue.severity === "ERROR" ? "error" : "warning";
12314
- const title = `${r.target.featureName}/${r.target.specName} ${issue.category}${issue.stepId ? ` (${issue.stepId})` : ""}`;
12315
- const body = issue.detail ? `${issue.message}\n${issue.detail}` : issue.message;
12316
- lines.push(`::${level} file=${file},title=${escapeGhProp(title)}::${escapeGhMessage(body)}`);
11956
+ const fields = [];
11957
+ if (remoteSpec.title !== spec.title) fields.push("title");
11958
+ if (!sameStringArray(remoteSpec.relatedPaths, spec.relatedPaths)) fields.push("relatedPaths");
11959
+ if (remoteSpec.status.mode !== spec.status.mode || remoteSpec.status.traced !== spec.status.traced || remoteSpec.status.generated !== spec.status.generated) fields.push(`status (local: ${formatStatus(spec.status)}, hub: ${formatStatus(remoteSpec.status)})`);
11960
+ if (fields.length > 0) issues.push(`${key}: out of date — ${fields.join(", ")}`);
11961
+ }
11962
+ for (const key of remoteMap.keys()) if (!seen.has(key)) issues.push(`${key}: no longer exists locally (stale hub entry)`);
11963
+ return issues;
11964
+ }
11965
+ function formatStatus(status) {
11966
+ return `${status.mode}/traced=${status.traced}/generated=${status.generated}`;
11967
+ }
11968
+ /** Order-sensitive equality; an absent list and an empty list are the same thing. */
11969
+ function sameStringArray(a, b) {
11970
+ const left = a ?? [];
11971
+ const right = b ?? [];
11972
+ return left.length === right.length && left.every((v, i) => v === right[i]);
11973
+ }
11974
+ /** Perspectives live on the hub only — no hub, no place to store (or check) them. */
11975
+ function requireHubOrExit(opts) {
11976
+ try {
11977
+ return requireHubClient(opts);
11978
+ } catch (err) {
11979
+ if (err instanceof HubConnectionError) {
11980
+ error(err.message);
11981
+ hint("perspectives are stored on the hub — start one with `ccqa serve`");
11982
+ process.exit(2);
12317
11983
  }
11984
+ throw err;
12318
11985
  }
12319
- return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
12320
- }
12321
- function githubRelPath(cwd, repoRoot, featureName, specName) {
12322
- const abs = resolve(cwd, ".ccqa", "features", featureName, "test-cases", specName, "spec.yaml");
12323
- const rel = relative(repoRoot, abs);
12324
- return rel.startsWith("..") ? abs : rel;
12325
- }
12326
- function escapeGhMessage(s) {
12327
- return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
12328
11986
  }
12329
- function escapeGhProp(s) {
12330
- return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/,/g, "%2C").replace(/:/g, "%3A");
12331
- }
12332
- function summarize(results) {
12333
- let error = 0;
12334
- let warn = 0;
12335
- let ok = 0;
12336
- let errored = 0;
12337
- for (const r of results) {
12338
- if (r.error) errored++;
12339
- for (const issue of r.issues) if (issue.severity === "ERROR") error++;
12340
- else if (issue.severity === "WARN") warn++;
12341
- else ok++;
11987
+ async function runPerspectives(opts) {
11988
+ const hub = requireHubOrExit(opts);
11989
+ const project = resolveProject(opts);
11990
+ header("perspectives", `project: ${project}`);
11991
+ const skeleton = await buildSkeleton(await listFeatureTree());
11992
+ const allSpecs = skeleton.flatMap((f) => f.specs);
11993
+ if (allSpecs.length === 0) {
11994
+ info("no test cases found under .ccqa/features — nothing to inventory.");
11995
+ return;
12342
11996
  }
12343
- return {
12344
- error,
12345
- warn,
12346
- ok,
12347
- errored
12348
- };
11997
+ const existingDoc = await hub.getPerspectives(project);
11998
+ const noteMap = extractNotes(existingDoc);
11999
+ const specBodies = await loadSpecBodies(skeleton);
12000
+ meta("language", opts.language ?? "auto");
12001
+ info(`Summarising ${allSpecs.length} test case(s) across ${skeleton.length} feature(s)...`);
12002
+ const summaries = await requestSummaries(specBodies, opts);
12003
+ if (summaries === null) process.exit(1);
12004
+ const merged = mergePerspectives(skeleton, summaries, noteMap);
12005
+ let validated;
12006
+ try {
12007
+ validated = PerspectivesSchema.parse(merged);
12008
+ } catch (e) {
12009
+ error(`refused to push: assembled perspectives failed validation (${e.message})`);
12010
+ process.exit(1);
12011
+ }
12012
+ const existingYaml = existingDoc === null ? "" : stringify(existingDoc, { lineWidth: 0 });
12013
+ const next = stringify(validated, { lineWidth: 0 });
12014
+ if (withoutGeneratedAt(existingYaml) === withoutGeneratedAt(next)) {
12015
+ blank();
12016
+ info("perspectives already up to date — no changes.");
12017
+ await cleanupLegacyLocalFiles();
12018
+ return;
12019
+ }
12020
+ blank();
12021
+ info("--- proposed changes (YAML view of the hub document) ---");
12022
+ printUnifiedDiff(existingYaml, next);
12023
+ blank();
12024
+ if (!(opts.apply === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "hub に perspectives を保存しますか? [y/N] " : "Push perspectives to the hub? [y/N] ")))) {
12025
+ info("aborted — no changes written.");
12026
+ return;
12027
+ }
12028
+ await hub.putPerspectives(project, validated);
12029
+ meta("pushed", `perspectives (project: ${project})`);
12030
+ await cleanupLegacyLocalFiles();
12349
12031
  }
12350
- //#endregion
12351
- //#region src/drift/exit-code.ts
12352
12032
  /**
12353
- * Map drift results to an exit code. Spec-level errors (Claude call failed)
12354
- * always fail; otherwise ERROR severity always fails, WARN fails only when
12355
- * the threshold is `warn`.
12033
+ * Perspectives used to be written into the repo (`.ccqa/perspectives.yaml`,
12034
+ * `.ccqa/perspectives.md`, `.ccqa/features/<f>/perspectives.md`). Now that
12035
+ * the document is hub-only, sweep those leftovers whenever the command runs
12036
+ * so consuming repos converge without a manual cleanup.
12356
12037
  */
12357
- function determineExitCode(results, threshold) {
12358
- for (const r of results) {
12359
- if (r.error) return 1;
12360
- for (const issue of r.issues) {
12361
- if (issue.severity === "ERROR") return 1;
12362
- if (threshold === "warn" && issue.severity === "WARN") return 1;
12363
- }
12364
- }
12365
- return 0;
12038
+ async function cleanupLegacyLocalFiles() {
12039
+ const removed = await removeLegacyPerspectivesFiles();
12040
+ for (const path of removed) meta("removed legacy file", path);
12366
12041
  }
12367
12042
  /**
12368
- * Spec-level status under the given threshold, mirroring determineExitCode's
12369
- * per-issue logic (exit-code.ts) but scoped to a single SpecResult.
12043
+ * Turn the feature tree into the skeleton perspectives features: title +
12044
+ * relatedPaths transcribed from each spec, status derived mechanically from
12045
+ * on-disk artifacts. `summary` is left empty here; Claude fills it later.
12046
+ * Specs whose spec.yaml is missing or unparsable are skipped.
12370
12047
  */
12371
- function specStatus(result, threshold) {
12372
- if (result.error) return "failed";
12373
- for (const issue of result.issues) {
12374
- if (issue.severity === "ERROR") return "failed";
12375
- if (threshold === "warn" && issue.severity === "WARN") return "failed";
12376
- }
12377
- return "passed";
12048
+ async function buildSkeleton(tree) {
12049
+ return (await Promise.all(tree.map(async (feature) => {
12050
+ const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
12051
+ const spec = await readSpecMeta(feature.featureName, s.specName);
12052
+ const status = await deriveStatus(feature.featureName, s.specName, spec.mode);
12053
+ const entry = {
12054
+ specName: s.specName,
12055
+ title: spec.title,
12056
+ summary: "",
12057
+ status
12058
+ };
12059
+ if (s.relatedPaths) entry.relatedPaths = s.relatedPaths;
12060
+ return entry;
12061
+ }));
12062
+ return {
12063
+ featureName: feature.featureName,
12064
+ specs
12065
+ };
12066
+ }))).filter((f) => f.specs.length > 0).map((f) => ({
12067
+ featureName: f.featureName,
12068
+ specs: [...f.specs].sort((a, b) => a.specName.localeCompare(b.specName))
12069
+ })).sort((a, b) => a.featureName.localeCompare(b.featureName));
12378
12070
  }
12379
12071
  /**
12380
- * Adapts `ccqa drift` results into the shared RunReportData shape so they can
12381
- * be pushed to the hub (`ccqa drift --push`) and rendered by the same report
12382
- * UI as `ccqa run`/`ccqa live`. Browser-execution fields (testCounts,
12383
- * evidence, liveRun, ...) don't apply to a drift audit and are always null.
12072
+ * `(featureName, specName)` human note, extracted from the hub's current
12073
+ * perspectives document. Notes are preserved across regeneration; everything
12074
+ * else (title, status, summary) is recomputed. Returns an empty map when the
12075
+ * document is absent or doesn't match the schema note preservation is
12076
+ * best-effort and never blocks regeneration.
12384
12077
  */
12385
- function driftResultsToReport(results, meta) {
12386
- const specResults = results.map((result) => ({
12387
- feature: result.target.featureName,
12388
- spec: result.target.specName,
12389
- title: null,
12390
- status: specStatus(result, meta.threshold),
12391
- testCounts: null,
12392
- durationMs: null,
12393
- assertions: null,
12394
- analysis: null,
12395
- analysisSkipped: null,
12396
- driftIssues: result.issues,
12397
- failureLogExcerpt: null,
12398
- diffExcerpt: null,
12399
- specYaml: null,
12400
- evidence: null,
12401
- liveRun: null
12402
- }));
12403
- return {
12404
- schemaVersion: 1,
12405
- kind: "drift",
12406
- createdAt: meta.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
12407
- runId: meta.runId ?? null,
12408
- git: meta.git,
12409
- model: meta.model ?? null,
12410
- language: meta.language ?? null,
12411
- promptVersion: meta.promptVersion ?? "1",
12412
- customPromptVersion: null,
12413
- results: specResults
12414
- };
12078
+ function extractNotes(existing) {
12079
+ const map = /* @__PURE__ */ new Map();
12080
+ if (existing === null || existing === void 0) return map;
12081
+ const result = PerspectivesSchema.safeParse(existing);
12082
+ if (!result.success) return map;
12083
+ for (const feature of result.data.features) for (const spec of feature.specs) if (spec.note !== void 0 && spec.note !== "") map.set(noteKey(feature.featureName, spec.specName), spec.note);
12084
+ return map;
12415
12085
  }
12416
- //#endregion
12417
- //#region src/drift/route-new-files.ts
12418
12086
  /**
12419
- * Lightweight Claude call: given a list of new files in the PR and the existing
12420
- * specs (with their relatedPaths globs as a hint), return the spec keys (in
12421
- * "<feature>/<spec>" form) that the new files plausibly affect.
12422
- *
12423
- * Conservative by design — false positives are safer than false negatives,
12424
- * because a missed spec turns into undetected drift in CI. When the router
12425
- * call itself fails, we log a warning rather than fail-close: the surrounding
12426
- * glob match is the primary signal; the router only adds coverage for new
12427
- * paths no glob captures.
12087
+ * Merge the mechanical skeleton with Claude's summaries and the preserved
12088
+ * notes into the final perspectives object. Summaries are matched by
12089
+ * (featureName, specName); an unmatched spec keeps its empty summary.
12428
12090
  */
12429
- async function routeNewFilesToSpecs(input) {
12430
- const { newFiles, specs, cwd, model } = input;
12431
- const empty = /* @__PURE__ */ new Set();
12432
- if (newFiles.length === 0 || specs.length === 0) return empty;
12091
+ function mergePerspectives(skeleton, summaries, noteMap) {
12092
+ const summaryMap = /* @__PURE__ */ new Map();
12093
+ for (const s of summaries) summaryMap.set(noteKey(s.featureName, s.specName), s);
12094
+ const features = skeleton.map((feature) => ({
12095
+ featureName: feature.featureName,
12096
+ specs: feature.specs.map((spec) => {
12097
+ const key = noteKey(feature.featureName, spec.specName);
12098
+ const entry = summaryMap.get(key);
12099
+ const merged = {
12100
+ ...spec,
12101
+ summary: entry?.summary ?? spec.summary
12102
+ };
12103
+ if (entry?.startScreen) merged.startScreen = entry.startScreen;
12104
+ if (entry?.testCondition) merged.testCondition = entry.testCondition;
12105
+ if (entry?.preconditions && entry.preconditions.length > 0) merged.preconditions = entry.preconditions;
12106
+ const note = noteMap.get(key);
12107
+ if (note !== void 0) merged.note = note;
12108
+ return merged;
12109
+ })
12110
+ }));
12111
+ return {
12112
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12113
+ features
12114
+ };
12115
+ }
12116
+ /**
12117
+ * Strip the top-level `generatedAt:` line so two serialised perspectives can
12118
+ * be compared for substantive equality without the always-fresh timestamp
12119
+ * defeating the "already up to date" check. Exported for unit testing.
12120
+ */
12121
+ function withoutGeneratedAt(yamlText) {
12122
+ return yamlText.split("\n").filter((line) => !/^generatedAt:/.test(line)).join("\n").trim();
12123
+ }
12124
+ function noteKey(featureName, specName) {
12125
+ return `${featureName}/${specName}`;
12126
+ }
12127
+ async function readSpecMeta(featureName, specName) {
12128
+ const raw = await tryReadSpecFile(featureName, specName);
12129
+ if (raw === null) return {
12130
+ title: specName,
12131
+ mode: DEFAULT_SPEC_MODE
12132
+ };
12133
+ try {
12134
+ const parsed = parse(raw);
12135
+ const title = typeof parsed.title === "string" && parsed.title.length > 0 ? parsed.title : specName;
12136
+ const modeResult = SpecModeSchema.safeParse(parsed.mode);
12137
+ return {
12138
+ title,
12139
+ mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
12140
+ };
12141
+ } catch {
12142
+ return {
12143
+ title: specName,
12144
+ mode: DEFAULT_SPEC_MODE
12145
+ };
12146
+ }
12147
+ }
12148
+ async function deriveStatus(featureName, specName, mode) {
12149
+ return {
12150
+ mode,
12151
+ traced: await stat(join(getSpecDir(featureName, specName), "ir.json")).then(() => true).catch(() => false),
12152
+ generated: await getTestScript(featureName, specName) !== null
12153
+ };
12154
+ }
12155
+ async function loadSpecBodies(skeleton) {
12156
+ return await Promise.all(skeleton.flatMap((feature) => feature.specs.map(async (spec) => {
12157
+ const specYaml = await tryReadSpecFile(feature.featureName, spec.specName) ?? "";
12158
+ return {
12159
+ featureName: feature.featureName,
12160
+ specName: spec.specName,
12161
+ title: spec.title,
12162
+ specYaml
12163
+ };
12164
+ })));
12165
+ }
12166
+ async function requestSummaries(specs, opts) {
12167
+ const toolCounts = {};
12168
+ const startedAt = Date.now();
12433
12169
  const { result, isError } = await invokeClaudeStreaming({
12434
- prompt: buildRouterPrompt(await Promise.all(newFiles.map(async (path) => ({
12435
- path,
12436
- head: await readHead(join(cwd, path))
12437
- }))), specs),
12438
- systemPrompt: buildRouterSystemPrompt(),
12170
+ prompt: buildPerspectivesPrompt(specs, opts.instruction),
12171
+ systemPrompt: buildPerspectivesSystemPrompt() + languageDirective(opts.language),
12439
12172
  allowedTools: [
12440
12173
  "Read",
12441
12174
  "Grep",
12442
12175
  "Glob"
12443
12176
  ],
12444
12177
  silenceBashLog: true,
12445
- cwd,
12446
- ...model ? { model } : {}
12447
- }, (_msg) => {});
12178
+ ...opts.model ? { model: opts.model } : {}
12179
+ }, (msg) => {
12180
+ if (msg.type !== "assistant") return;
12181
+ for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
12182
+ });
12183
+ process.stdout.write(`${formatToolSummary(toolCounts, Date.now() - startedAt)}\n`);
12448
12184
  if (isError) {
12449
- warn("new-file router: Claude returned an error; skipping router signal");
12450
- return empty;
12185
+ error("Claude returned an error result");
12186
+ return null;
12451
12187
  }
12452
12188
  const json = extractJsonBlock(result);
12453
12189
  if (!json) {
12454
- warn("new-file router: no JSON block in response; skipping router signal");
12455
- return empty;
12190
+ error("Claude did not return a json block");
12191
+ return null;
12456
12192
  }
12457
- let parsed;
12193
+ return parseSummaries(json);
12194
+ }
12195
+ /**
12196
+ * Parse the `{ summaries: [...] }` JSON contract into typed entries. Returns
12197
+ * null and logs when the payload is malformed. Exported for unit testing.
12198
+ */
12199
+ function parseSummaries(json) {
12200
+ let payload;
12458
12201
  try {
12459
- parsed = JSON.parse(json);
12202
+ payload = JSON.parse(json);
12460
12203
  } catch (e) {
12461
- warn(`new-file router: failed to parse JSON (${e.message}); skipping router signal`);
12462
- return empty;
12204
+ error(`failed to parse summaries JSON: ${e.message}`);
12205
+ return null;
12463
12206
  }
12464
- const out = /* @__PURE__ */ new Set();
12465
- const validKeys = new Set(specs.map((s) => `${s.featureName}/${s.specName}`));
12466
- if (typeof parsed === "object" && parsed !== null && "affectedSpecs" in parsed) {
12467
- const arr = parsed.affectedSpecs;
12468
- if (Array.isArray(arr)) {
12469
- for (const item of arr) if (typeof item === "string" && validKeys.has(item)) out.add(item);
12207
+ if (typeof payload !== "object" || payload === null) {
12208
+ error("summaries payload is not an object");
12209
+ return null;
12210
+ }
12211
+ const summaries = payload.summaries;
12212
+ if (!Array.isArray(summaries)) {
12213
+ error("summaries payload missing a `summaries` array");
12214
+ return null;
12215
+ }
12216
+ const out = [];
12217
+ for (const item of summaries) {
12218
+ const rec = item ?? {};
12219
+ const { featureName, specName, summary } = rec;
12220
+ if (typeof featureName === "string" && typeof specName === "string" && typeof summary === "string") {
12221
+ const entry = {
12222
+ featureName,
12223
+ specName,
12224
+ summary
12225
+ };
12226
+ if (typeof rec.startScreen === "string" && rec.startScreen.length > 0) entry.startScreen = rec.startScreen;
12227
+ if (typeof rec.testCondition === "string" && rec.testCondition.length > 0) entry.testCondition = rec.testCondition;
12228
+ if (Array.isArray(rec.preconditions)) {
12229
+ const pre = rec.preconditions.filter((p) => typeof p === "string" && p.length > 0);
12230
+ if (pre.length > 0) entry.preconditions = pre;
12231
+ }
12232
+ out.push(entry);
12470
12233
  }
12471
12234
  }
12472
12235
  return out;
12473
12236
  }
12474
- async function readHead(absPath) {
12475
- const content = await readFile(absPath, "utf-8").catch(() => "");
12476
- if (!content) return "";
12477
- return content.split("\n").slice(0, 40).join("\n");
12478
- }
12479
- function buildRouterSystemPrompt() {
12480
- return `You triage which ccqa test specs are potentially affected by NEW source files added in a pull request.
12481
-
12482
- You will receive:
12483
- - A list of new files (path + first ~40 lines of each)
12484
- - A list of existing specs with their declared relatedPaths globs
12485
-
12486
- Your job: return the spec keys (in "<feature>/<spec>" form) whose behaviour might depend on any of the new files.
12487
-
12488
- ## Rules
12489
-
12490
- - Be **conservative**: when in doubt, include the spec. A spurious inclusion costs one extra drift check; a missed spec lets real drift slip through CI.
12491
- - Use \`Read\`, \`Grep\`, \`Glob\` if you need to inspect the spec body or related code, but stay focused — this is a triage step, not a full review.
12492
- - Ignore specs whose relatedPaths clearly point to a different area than every new file (e.g. \`src/auth/**\` specs vs new files only under \`src/billing/**\`).
12493
- - Files like tests, generated code, build artifacts, vendor dirs typically do not affect any spec. Skip them.
12494
-
12495
- ## Output (STRICT)
12496
-
12497
- Output ONE fenced \`\`\`json block, nothing else:
12498
-
12499
- \`\`\`json
12500
- {
12501
- "affectedSpecs": ["feature/spec", "feature/spec"]
12237
+ //#endregion
12238
+ //#region src/cli/perspectives-sync.ts
12239
+ /**
12240
+ * Best-effort incremental update of the hub-stored perspectives document
12241
+ * after a successful `ccqa record` / `ccqa generate`: upsert just this spec's
12242
+ * entry (mechanical facts recomputed, descriptive fields rewritten by one
12243
+ * small Claude call, the human `note` preserved). The full `ccqa perspectives`
12244
+ * run remains the way to regenerate everything and prune deleted specs.
12245
+ *
12246
+ * Never fails the caller: no hub configured no-op; any error → warning.
12247
+ */
12248
+ async function syncSpecPerspectives(hubContext, opts) {
12249
+ if (!hubContext) return;
12250
+ try {
12251
+ await doSync(hubContext, opts);
12252
+ } catch (err) {
12253
+ warn(`perspectives auto-update skipped: ${err instanceof Error ? err.message : String(err)}`);
12254
+ }
12502
12255
  }
12503
- \`\`\`
12504
-
12505
- Use exactly the keys you saw in the input ("<feature>/<spec>"). Return an empty array if no spec is affected.
12506
- `;
12256
+ async function doSync(ctx, opts) {
12257
+ const { featureName, specName } = opts.ref;
12258
+ const specYaml = await tryReadSpecFile(featureName, specName);
12259
+ if (specYaml === null) return;
12260
+ const existing = await ctx.hub.getPerspectives(ctx.project);
12261
+ let doc;
12262
+ if (existing === null) doc = {
12263
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12264
+ features: []
12265
+ };
12266
+ else {
12267
+ const parsed = PerspectivesSchema.safeParse(existing);
12268
+ if (!parsed.success) {
12269
+ warn("perspectives auto-update skipped: the hub document does not match the schema — run `ccqa perspectives` to regenerate it");
12270
+ return;
12271
+ }
12272
+ doc = parsed.data;
12273
+ }
12274
+ const meta$1 = await readSpecMeta(featureName, specName);
12275
+ const status = await deriveStatus(featureName, specName, meta$1.mode);
12276
+ const relatedPaths = extractRelatedPaths(specYaml);
12277
+ const previous = findSpec(doc, featureName, specName);
12278
+ const written = (await requestSummaries([{
12279
+ featureName,
12280
+ specName,
12281
+ title: meta$1.title,
12282
+ specYaml
12283
+ }], {
12284
+ ...opts.language ? { language: opts.language } : {},
12285
+ ...opts.model ? { model: opts.model } : {}
12286
+ }))?.[0];
12287
+ const entry = {
12288
+ specName,
12289
+ title: meta$1.title,
12290
+ summary: written?.summary ?? previous?.summary ?? "",
12291
+ status
12292
+ };
12293
+ const startScreen = written?.startScreen ?? previous?.startScreen;
12294
+ if (startScreen) entry.startScreen = startScreen;
12295
+ const testCondition = written?.testCondition ?? previous?.testCondition;
12296
+ if (testCondition) entry.testCondition = testCondition;
12297
+ const preconditions = written?.preconditions ?? previous?.preconditions;
12298
+ if (preconditions && preconditions.length > 0) entry.preconditions = preconditions;
12299
+ if (relatedPaths.length > 0) entry.relatedPaths = relatedPaths;
12300
+ if (previous?.note) entry.note = previous.note;
12301
+ upsertSpec(doc, featureName, entry);
12302
+ doc.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
12303
+ await ctx.hub.putPerspectives(ctx.project, PerspectivesSchema.parse(doc));
12304
+ meta("perspectives", `updated ${featureName}/${specName} on the hub`);
12305
+ }
12306
+ function findSpec(doc, featureName, specName) {
12307
+ return doc.features.find((f) => f.featureName === featureName)?.specs.find((s) => s.specName === specName);
12308
+ }
12309
+ /** Replace-or-insert the spec entry, keeping features and specs name-sorted like a full regeneration. */
12310
+ function upsertSpec(doc, featureName, entry) {
12311
+ let feature = doc.features.find((f) => f.featureName === featureName);
12312
+ if (!feature) {
12313
+ feature = {
12314
+ featureName,
12315
+ specs: []
12316
+ };
12317
+ doc.features.push(feature);
12318
+ doc.features.sort((a, b) => a.featureName.localeCompare(b.featureName));
12319
+ }
12320
+ const idx = feature.specs.findIndex((s) => s.specName === entry.specName);
12321
+ if (idx >= 0) feature.specs[idx] = entry;
12322
+ else {
12323
+ feature.specs.push(entry);
12324
+ feature.specs.sort((a, b) => a.specName.localeCompare(b.specName));
12325
+ }
12507
12326
  }
12508
- function buildRouterPrompt(previews, specs) {
12509
- return `## New files
12510
-
12511
- ${previews.map((p) => {
12512
- const headBlock = p.head ? `\n\`\`\`\n${p.head}\n\`\`\`` : "\n(empty or unreadable)";
12513
- return `### ${p.path}${headBlock}`;
12514
- }).join("\n\n")}
12515
-
12516
- ## Existing specs
12517
-
12518
- ${specs.map((s) => {
12519
- const paths = s.relatedPaths.length === 0 ? " (no relatedPaths declared)" : s.relatedPaths.map((p) => ` - ${p}`).join("\n");
12520
- return `- ${s.featureName}/${s.specName}\n${paths}`;
12521
- }).join("\n")}
12522
-
12523
- ## Task
12524
-
12525
- Return the spec keys that might be affected by any of the new files. Conservative inclusion is preferred over missing real drift.
12526
- `;
12327
+ /** `relatedPaths` transcribed verbatim from the spec.yaml (empty when absent/unparsable). */
12328
+ function extractRelatedPaths(specYaml) {
12329
+ try {
12330
+ const parsed = parse(specYaml);
12331
+ if (!Array.isArray(parsed?.relatedPaths)) return [];
12332
+ return parsed.relatedPaths.filter((p) => typeof p === "string" && p.length > 0);
12333
+ } catch {
12334
+ return [];
12335
+ }
12527
12336
  }
12528
12337
  //#endregion
12529
- //#region src/cli/drift.ts
12530
- const DEFAULT_CONCURRENCY = 3;
12531
- const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Standalone spec ↔ codebase static audit. Use for PR checks where the browser isn't run. For run-time audit with a structured report, see `ccqa run --report`.").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). New files are routed to specs via a single lightweight Claude call.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI) or origin/main.").option("--push", "Push the drift result to a ccqa hub as a run (kind: drift).").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption)).action(async (specPath, opts) => {
12532
- const format = parseFormat(opts.format);
12533
- const threshold = parseSeverity(opts.severity);
12534
- const concurrency = parseConcurrency(opts.concurrency);
12535
- const cwd = resolveCwd(opts.cwd);
12536
- await ensureCcqaDir(cwd);
12537
- if (opts.changed && specPath) {
12538
- error("--changed and an explicit spec id cannot be combined; --changed only applies to a full sweep");
12539
- process.exit(2);
12540
- }
12541
- let targets = await collectTargets(specPath, cwd);
12542
- if (targets.length === 0) exitWithNoSpecs(format, "no test specs found under .ccqa/features/");
12543
- if (format === "text") {
12544
- header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
12545
- if (opts.cwd) meta("cwd", cwd);
12338
+ //#region src/cli/generate.ts
12339
+ const AUTO_FIX_MODES = [
12340
+ "interactive",
12341
+ "auto",
12342
+ "skip"
12343
+ ];
12344
+ function toFixMode(autoFix) {
12345
+ switch (autoFix) {
12346
+ case "auto": return "auto";
12347
+ case "skip": return "non-interactive";
12348
+ case "interactive": return "interactive";
12546
12349
  }
12547
- const baseRef = opts.changed ? resolveBaseRef(opts.base) : null;
12548
- if (opts.changed) {
12549
- const total = targets.length;
12550
- targets = await filterByChanged({
12551
- targets,
12552
- cwd,
12553
- baseOverride: opts.base,
12554
- format,
12555
- model: opts.model
12556
- });
12557
- if (format === "text") meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
12558
- if (targets.length === 0) exitWithNoSpecs(format, "no specs intersect the changed file set; nothing to check");
12350
+ }
12351
+ /** Shared `--auto-fix` parser for the record / generate commands. */
12352
+ function parseAutoFixFlag(raw) {
12353
+ if (AUTO_FIX_MODES.includes(raw)) return raw;
12354
+ throw new Error(`--auto-fix must be one of ${AUTO_FIX_MODES.join(" | ")}`);
12355
+ }
12356
+ /**
12357
+ * The `generate` flow shared by `ccqa generate` and the codegen half of
12358
+ * `ccqa record`: resolve the spec's target plugin, load its input (the
12359
+ * recording, for input:"recording" targets), and dispatch to the plugin.
12360
+ * This layer owns the CLI concerns overwrite confirmation, logging,
12361
+ * exit-code policy while the plugin owns the generation pipeline.
12362
+ */
12363
+ async function runGenerate(featureName, specName, opts) {
12364
+ header("generate", `${featureName}/${specName}`);
12365
+ const cwd = opts.cwd ?? process.cwd();
12366
+ await ensureCcqaDir(cwd);
12367
+ const releaseLock = await acquireSpecLock(featureName, specName, "generate", cwd);
12368
+ try {
12369
+ await runGenerateLocked(featureName, specName, opts, cwd);
12370
+ } finally {
12371
+ await releaseLock();
12559
12372
  }
12560
- const blocks = await loadAvailableBlocks(cwd);
12561
- const results = await analyzeDrift({
12562
- targets,
12373
+ }
12374
+ async function runGenerateLocked(featureName, specName, opts, cwd) {
12375
+ const specYaml = await readSpecFile(featureName, specName, cwd);
12376
+ const spec = parseTestSpec(specYaml);
12377
+ const config = await loadProjectConfig(cwd);
12378
+ const target = opts.targetOverride !== void 0 ? resolveTargetOverride(spec, opts.targetOverride) : resolveTarget(spec, config);
12379
+ meta("target", target.id + (opts.targetOverride !== void 0 ? " (--target override)" : ""));
12380
+ const existingOutput = await target.existingOutput?.({
12381
+ featureName,
12382
+ specName
12383
+ }, cwd) ?? null;
12384
+ if (existingOutput && !opts.force) {
12385
+ if (!await confirmOverwrite(existingOutput)) {
12386
+ info("aborted; pass --force to overwrite without prompting");
12387
+ return;
12388
+ }
12389
+ }
12390
+ let recording;
12391
+ if (target.input === "recording") {
12392
+ const { path: recordingPath, actions } = await getRecording(featureName, specName, cwd);
12393
+ meta("recording", recordingPath);
12394
+ meta("actions", actions.length);
12395
+ recording = actions;
12396
+ }
12397
+ await warnStaleBlockArtifacts();
12398
+ const targetConfig = config.targets[target.id] ?? TargetConfigSchema.parse({});
12399
+ const ctx = {
12400
+ spec,
12401
+ specYaml,
12402
+ featureName,
12403
+ specName,
12563
12404
  cwd,
12564
- blocks,
12565
- concurrency,
12566
- ...opts.model ? { model: opts.model } : {},
12567
- ...opts.language ? { language: opts.language } : {},
12568
- onSpecStart: (t) => {
12569
- if (format === "text") info(`checking ${t.featureName}/${t.specName}`);
12405
+ recording,
12406
+ resources: targetConfig.resources,
12407
+ conventions: targetConfig.conventions,
12408
+ targetConfig,
12409
+ language: opts.language,
12410
+ model: opts.model,
12411
+ hub: opts.hubContext ?? null,
12412
+ fix: {
12413
+ maxRetries: opts.maxRetries,
12414
+ mode: opts.fixMode,
12415
+ useSnapshot: opts.useSnapshot
12570
12416
  }
12417
+ };
12418
+ if (!(await target.generate(ctx)).passed) {
12419
+ warn("auto-fix exhausted; test still failing");
12420
+ process.exit(1);
12421
+ }
12422
+ hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
12423
+ }
12424
+ async function confirmOverwrite(path) {
12425
+ if (!process.stdin.isTTY) {
12426
+ warn(`${path} exists and stdin is not a TTY; refusing to overwrite. Pass --force to allow.`);
12427
+ return false;
12428
+ }
12429
+ const rl = createInterface({
12430
+ input: process.stdin,
12431
+ output: process.stdout
12571
12432
  });
12572
- process.stdout.write(renderDrift(results, format, cwd));
12573
- if (opts.push) await pushDriftResults({
12574
- results,
12575
- threshold,
12433
+ try {
12434
+ process.stdout.write("\n");
12435
+ process.stdout.write(`[warn] ${path} already exists.\n`);
12436
+ process.stdout.write(`[warn] generate will regenerate it and any manual edits will be lost.\n`);
12437
+ const norm = (await new Promise((res) => rl.question("Overwrite? [y/N] ", res))).trim().toLowerCase();
12438
+ return norm === "y" || norm === "yes";
12439
+ } finally {
12440
+ rl.close();
12441
+ }
12442
+ }
12443
+ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate test code from a spec via its target plugin. Recording-backed targets compile the existing ir.json (run `ccqa record` first); spec-input targets generate directly from the spec.").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--target <id>", "Generate through this target instead of the spec's own — e.g. emit a Playwright spec from an agent-browser recording. The spec's `target:` stays the default for `ccqa run`.").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N), 'auto' (apply without prompt, for CI), 'skip' (never prompt, only apply high-confidence fixes).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--force", "Overwrite previously generated test code without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (specPath, opts) => {
12444
+ const { featureName, specName } = parseSpecPath(specPath);
12445
+ const language = opts.language ?? "auto";
12446
+ const cwd = resolveCwd(opts.cwd);
12447
+ const hubClient = resolveHubClient({
12448
+ hubUrl: opts.hubUrl,
12449
+ hubToken: opts.hubToken,
12450
+ hubHeader: opts.hubHeader
12451
+ });
12452
+ const project = opts.profile !== void 0 || hubClient !== null ? resolveProject(opts) : void 0;
12453
+ if (opts.profile !== void 0) await applyProfileFromOption({
12454
+ profile: opts.profile,
12455
+ project,
12576
12456
  cwd,
12577
- opts,
12578
- format,
12579
- baseRef
12457
+ hubUrl: opts.hubUrl,
12458
+ hubToken: opts.hubToken,
12459
+ hubHeader: opts.hubHeader
12580
12460
  });
12581
- process.exit(determineExitCode(results, threshold));
12582
- });
12583
- /**
12584
- * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
12585
- * shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
12586
- * hub connection warns and returns rather than failing the command (`--push`
12587
- * never changes drift's own exit code).
12588
- *
12589
- * `resolveHub` is injectable so tests can supply a fake `HubClient` without
12590
- * a real hub connection; it defaults to the real flag/env resolution.
12591
- */
12592
- async function pushDriftResults(args, resolveHub = resolveHubClient) {
12593
- const { results, threshold, cwd, opts, format, baseRef } = args;
12594
- const hub = resolveHub(opts);
12595
- if (!hub) {
12596
- warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
12597
- return;
12598
- }
12461
+ else await applyProfileFromOption({
12462
+ profile: void 0,
12463
+ project: "",
12464
+ cwd
12465
+ });
12466
+ const hubContext = hubClient && project ? {
12467
+ hub: hubClient,
12468
+ project
12469
+ } : null;
12599
12470
  try {
12600
- const project = resolveProject({
12601
- project: opts.project,
12602
- cwd
12603
- });
12604
- const [branch, head] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
12605
- const report = driftResultsToReport(results, {
12606
- threshold,
12607
- git: {
12608
- head,
12609
- base: baseRef ?? null
12610
- }
12471
+ await runGenerate(featureName, specName, {
12472
+ maxRetries: parseInt(opts.maxRetries ?? "3", 10),
12473
+ fixMode: toFixMode(opts.autoFix ?? "interactive"),
12474
+ force: opts.force ?? false,
12475
+ useSnapshot: opts.snapshot !== false,
12476
+ language,
12477
+ model: opts.model,
12478
+ targetOverride: opts.target,
12479
+ cwd,
12480
+ hubContext
12611
12481
  });
12612
- const dir = await mkdtemp(join(tmpdir(), "ccqa-drift-push-"));
12613
- try {
12614
- await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2), "utf8");
12615
- const archive = await packDirToTarGz(dir);
12616
- const run = await hub.pushRun(archive, {
12617
- project,
12618
- ...branch ? { branch } : {},
12619
- kind: "drift"
12620
- });
12621
- if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${run.id}`);
12622
- } finally {
12623
- await rm(dir, {
12624
- recursive: true,
12625
- force: true
12626
- });
12627
- }
12628
- } catch (err) {
12629
- if (err instanceof HubApiError) {
12630
- error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
12482
+ } catch (e) {
12483
+ if (e instanceof SpecLockedError) {
12484
+ error(e.message);
12631
12485
  process.exit(2);
12632
12486
  }
12633
- throw err;
12487
+ throw e;
12634
12488
  }
12635
- }
12636
- function exitWithNoSpecs(format, message) {
12637
- if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
12638
- else if (format === "text") info(message);
12639
- process.exit(0);
12640
- }
12641
- async function filterByChanged(input) {
12642
- const { targets, cwd, baseOverride, format, model } = input;
12643
- const base = resolveBaseRef(baseOverride);
12644
- let changed;
12645
- try {
12646
- changed = await getChangedFiles(base, cwd);
12647
- } catch (e) {
12648
- error(`failed to run 'git diff' against ${base}: ${e.message}`);
12489
+ await syncSpecPerspectives(hubContext, {
12490
+ ref: {
12491
+ featureName,
12492
+ specName
12493
+ },
12494
+ ...language ? { language } : {},
12495
+ ...opts.model ? { model: opts.model } : {}
12496
+ });
12497
+ });
12498
+ //#endregion
12499
+ //#region src/cli/record.ts
12500
+ const VALIDATION_MODES = ["lenient", "strict"];
12501
+ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("record").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Record a deterministic test from a spec: run agent-browser to collect actions (trace), then generate test.spec.ts with auto-fix retries (generate). After recording, `ccqa run <feature/spec>` replays it under vitest (deterministic specs only — live specs do not need recording).").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--validation-mode <mode>", "Post-trace validation behaviour: 'lenient' (default) tags failing actions; 'strict' drops them.", (raw) => {
12502
+ if (VALIDATION_MODES.includes(raw)) return raw;
12503
+ throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
12504
+ }, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N), 'auto' (apply without prompt, for CI), 'skip' (never prompt, only apply high-confidence fixes).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--force", "Overwrite an existing test.spec.ts without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--skip-trace", "Skip the trace step and run codegen against an existing ir.json").option("--skip-codegen", "Run only the trace step (do not generate test.spec.ts)").option("--update-agent-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (specPath, opts) => {
12505
+ const { featureName, specName } = parseSpecPath(specPath);
12506
+ const language = opts.language ?? "auto";
12507
+ if (opts.skipTrace && opts.skipCodegen) {
12508
+ error("--skip-trace and --skip-codegen cannot be combined; nothing would run");
12649
12509
  process.exit(2);
12650
12510
  }
12651
- if (format === "text") {
12652
- meta("changed-base", base);
12653
- meta("changed-files", changed.length);
12511
+ const cwdForProfile = resolveCwd(opts.cwd);
12512
+ const target = resolveTarget(parseTestSpec(await readSpecFile(featureName, specName, cwdForProfile)), await loadProjectConfig(cwdForProfile));
12513
+ if (target.input === "spec") {
12514
+ error(`target "${target.id}" does not use a browser recording — run 'ccqa generate ${featureName}/${specName}' instead`);
12515
+ process.exit(2);
12654
12516
  }
12655
- if (changed.length === 0) return [];
12656
- const newFiles = changed.filter((f) => f.status === "added" && !f.outsideCwd);
12657
- const existingChanges = changed.filter((f) => f.status !== "added" || f.outsideCwd);
12658
- const affected = /* @__PURE__ */ new Set();
12659
- const touchedBlockNames = /* @__PURE__ */ new Set();
12660
- for (const f of changed) {
12661
- if (f.outsideCwd) continue;
12662
- const blockName = parseBlockPath(f.path);
12663
- if (blockName) touchedBlockNames.add(blockName);
12664
- }
12665
- for (const t of targets) {
12666
- if (!t.relatedPaths) {
12667
- affected.add(specKey(t));
12668
- continue;
12517
+ const project = opts.profile !== void 0 ? resolveProject(opts) : void 0;
12518
+ if (opts.profile !== void 0) await applyProfileFromOption({
12519
+ profile: opts.profile,
12520
+ project,
12521
+ cwd: cwdForProfile,
12522
+ hubUrl: opts.hubUrl,
12523
+ hubToken: opts.hubToken,
12524
+ hubHeader: opts.hubHeader
12525
+ });
12526
+ else await applyProfileFromOption({
12527
+ profile: void 0,
12528
+ project: "",
12529
+ cwd: cwdForProfile
12530
+ });
12531
+ const hubClientForTrace = resolveHubClient({
12532
+ hubUrl: opts.hubUrl,
12533
+ hubToken: opts.hubToken,
12534
+ hubHeader: opts.hubHeader
12535
+ });
12536
+ const hubProject = project ?? (hubClientForTrace !== null ? resolveProject(opts) : void 0);
12537
+ const hubContext = hubClientForTrace && hubProject ? {
12538
+ hub: hubClientForTrace,
12539
+ project: hubProject
12540
+ } : null;
12541
+ const releaseLock = await acquireSpecLock(featureName, specName, "record", cwdForProfile).catch((e) => {
12542
+ if (e instanceof SpecLockedError) {
12543
+ error(e.message);
12544
+ process.exit(2);
12669
12545
  }
12670
- if (existingChanges.some((f) => isPathAffectedBy(f.path, t.relatedPaths)) || newFiles.some((f) => isPathAffectedBy(f.path, t.relatedPaths))) {
12671
- affected.add(specKey(t));
12672
- continue;
12546
+ throw e;
12547
+ });
12548
+ let traceResult = null;
12549
+ try {
12550
+ if (!opts.skipTrace) {
12551
+ traceResult = await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient", language, {
12552
+ cwd: cwdForProfile,
12553
+ hubContext
12554
+ });
12555
+ blank();
12673
12556
  }
12674
- if (t.includedBlocks?.some((name) => touchedBlockNames.has(name))) affected.add(specKey(t));
12557
+ if (!opts.skipCodegen) await runGenerate(featureName, specName, {
12558
+ maxRetries: parseInt(opts.maxRetries ?? "3", 10),
12559
+ fixMode: toFixMode(opts.autoFix ?? "interactive"),
12560
+ force: opts.force ?? false,
12561
+ useSnapshot: opts.snapshot !== false,
12562
+ language,
12563
+ model: opts.model,
12564
+ cwd: cwdForProfile,
12565
+ hubContext
12566
+ });
12567
+ } finally {
12568
+ await releaseLock();
12675
12569
  }
12676
- if (newFiles.length > 0) {
12677
- if (format === "text") info(`routing ${newFiles.length} new file(s) to specs via Claude...`);
12678
- const routed = await routeNewFilesToSpecs({
12679
- newFiles: newFiles.map((f) => f.path),
12680
- specs: targets.filter((t) => t.relatedPaths).map((t) => ({
12681
- featureName: t.featureName,
12682
- specName: t.specName,
12683
- relatedPaths: t.relatedPaths
12684
- })),
12685
- cwd,
12686
- model
12570
+ if (opts.updateAgentPrompt) if (traceResult === null) warn("--update-agent-prompt is ignored when --skip-trace is set (no run summary available)");
12571
+ else {
12572
+ blank();
12573
+ await updateAgentPrompt({
12574
+ mode: "record",
12575
+ runSummary: buildRecordRunSummary(featureName, specName, traceResult),
12576
+ hubContext,
12577
+ ...opts.model ? { model: opts.model } : {},
12578
+ ...language ? { language } : {}
12687
12579
  });
12688
- for (const key of routed) affected.add(key);
12689
12580
  }
12690
- return targets.filter((t) => affected.has(specKey(t)));
12581
+ await syncSpecPerspectives(hubContext, {
12582
+ ref: {
12583
+ featureName,
12584
+ specName
12585
+ },
12586
+ ...language ? { language } : {},
12587
+ ...opts.model ? { model: opts.model } : {}
12588
+ });
12589
+ });
12590
+ /**
12591
+ * Compact summary of the trace pass for the record agent-prompt refresh.
12592
+ * Steps are reconstructed from the trace's status-line protocol (STEP_START
12593
+ * gives the title, STEP_DONE / ASSERTION_FAILED / STEP_SKIPPED the outcome),
12594
+ * and each step carries its per-action observations plus the **concrete kept
12595
+ * commands** — the selectors that actually survived scrub / dedup /
12596
+ * validation. The record playbook is told to record canonical selectors, so
12597
+ * it needs those exact tokens; the prose alone doesn't carry them. The
12598
+ * header's kept/recorded totals flag how much the run thrashed through
12599
+ * selectors overall.
12600
+ */
12601
+ function buildRecordRunSummary(featureName, specName, t) {
12602
+ const header = `## ${featureName}/${specName} — ${t.status}\nActions: ${t.actionsKept} kept / ${t.actionsRecorded} recorded`;
12603
+ const steps = collectStepSummaries(t.statusLines);
12604
+ if (steps.length === 0) return `${header}\n\n(no step status lines recorded)`;
12605
+ const commandsByStep = groupCommandsByStep(t.actions);
12606
+ const observationsByStep = groupObservationsByStep(t.actions);
12607
+ const unstableByStep = countUnstableByStep(t.actions);
12608
+ return `${header}\n\n${steps.map((s) => {
12609
+ const cmds = commandsByStep.get(s.stepId) ?? [];
12610
+ const observations = observationsByStep.get(s.stepId) ?? [];
12611
+ const churn = t.churnByStep.get(s.stepId);
12612
+ const dropped = churn ? churn.recorded - churn.kept : 0;
12613
+ const redundant = churn?.redundant ?? 0;
12614
+ const unstable = unstableByStep.get(s.stepId) ?? 0;
12615
+ return [
12616
+ `### ${s.stepId} — ${oneLineSummary(s.title)} (${s.status})`,
12617
+ ...s.detail ? [`- result: ${oneLineSummary(s.detail)}`] : [],
12618
+ ...observations.length > 0 ? [`- observations: ${observations.map(oneLineSummary).join(" ; ")}`] : [],
12619
+ ...dropped > 0 ? [`- churn: ${churn.recorded} attempts → ${churn.kept} kept (${dropped} dropped)`] : [],
12620
+ ...redundant > 0 ? [`- redundant: ${redundant} field(s) entered via 2+ selectors (kept both — record one canonical selector)`] : [],
12621
+ ...unstable > 0 ? [`- replay-unstable: ${unstable} kept command(s) marked [unstable] — do NOT record these as canonical; prefer a more stable locator even if it costs one more probe`] : [],
12622
+ ...cmds.length > 0 ? [`- kept commands: ${cmds.join(" ; ")}`] : []
12623
+ ].join("\n");
12624
+ }).join("\n\n")}`;
12691
12625
  }
12692
- async function collectTargets(specPath, cwd) {
12693
- const tree = await listFeatureTree(cwd);
12694
- if (specPath) {
12695
- const { featureName, specName } = parseSpecPath(specPath);
12696
- const spec = tree.find((f) => f.featureName === featureName)?.specs.find((s) => s.specName === specName);
12697
- if (!spec?.hasSpecFile) {
12698
- error(`spec not found: ${featureName}/${specName} (under ${cwd})`);
12699
- process.exit(1);
12626
+ /**
12627
+ * Fold the ordered status lines into one entry per step: STEP_START opens
12628
+ * the entry (its detail is the step title); the terminal line sets the
12629
+ * outcome. A terminal line for a step that never emitted STEP_START still
12630
+ * gets an entry so its outcome isn't silently lost.
12631
+ */
12632
+ function collectStepSummaries(lines) {
12633
+ const byId = /* @__PURE__ */ new Map();
12634
+ const ordered = [];
12635
+ const ensure = (stepId, title) => {
12636
+ let entry = byId.get(stepId);
12637
+ if (!entry) {
12638
+ entry = {
12639
+ stepId,
12640
+ title,
12641
+ status: "NO_STATUS"
12642
+ };
12643
+ byId.set(stepId, entry);
12644
+ ordered.push(entry);
12700
12645
  }
12701
- return [{
12702
- featureName,
12703
- specName,
12704
- includedBlocks: spec.includedBlocks ?? []
12705
- }];
12706
- }
12707
- const out = [];
12708
- for (const feature of tree) for (const spec of feature.specs) {
12709
- if (!spec.hasSpecFile) continue;
12710
- const t = {
12711
- featureName: feature.featureName,
12712
- specName: spec.specName
12713
- };
12714
- if (spec.relatedPaths) t.relatedPaths = spec.relatedPaths;
12715
- if (spec.includedBlocks) t.includedBlocks = spec.includedBlocks;
12716
- out.push(t);
12646
+ return entry;
12647
+ };
12648
+ for (const line of lines) {
12649
+ if (!line.stepId || line.type === "RUN_COMPLETED") continue;
12650
+ if (line.type === "STEP_START") {
12651
+ ensure(line.stepId, line.detail);
12652
+ continue;
12653
+ }
12654
+ const entry = ensure(line.stepId, "(untitled)");
12655
+ entry.status = line.type === "STEP_DONE" ? "DONE" : line.type === "ASSERTION_FAILED" ? "FAILED" : "SKIPPED";
12656
+ if (line.detail) entry.detail = line.detail;
12717
12657
  }
12718
- return out;
12658
+ return ordered;
12719
12659
  }
12720
- function parseFormat(raw) {
12721
- const v = raw ?? "text";
12722
- if (v === "text" || v === "json" || v === "github") return v;
12723
- error(`invalid --format: ${v} (expected text|json|github)`);
12724
- process.exit(2);
12660
+ /** Group each kept action's `action selector` form under its stepId. */
12661
+ function groupCommandsByStep(actions) {
12662
+ const byStep = /* @__PURE__ */ new Map();
12663
+ for (const a of actions) {
12664
+ if (!a.stepId) continue;
12665
+ const list = byStep.get(a.stepId) ?? [];
12666
+ list.push(formatRecordedAction(a));
12667
+ byStep.set(a.stepId, list);
12668
+ }
12669
+ return byStep;
12725
12670
  }
12726
- function parseSeverity(raw) {
12727
- const v = raw ?? "error";
12728
- if (v === "warn" || v === "error") return v;
12729
- error(`invalid --severity: ${v} (expected warn|error)`);
12730
- process.exit(2);
12671
+ /**
12672
+ * Group per-action observations (snapshot / assert prose) under their stepId
12673
+ * the trace's own record of what it verified at each step.
12674
+ */
12675
+ function groupObservationsByStep(actions) {
12676
+ const byStep = /* @__PURE__ */ new Map();
12677
+ for (const a of actions) {
12678
+ if (!a.stepId || !a.observation) continue;
12679
+ const list = byStep.get(a.stepId) ?? [];
12680
+ list.push(a.observation);
12681
+ byStep.set(a.stepId, list);
12682
+ }
12683
+ return byStep;
12731
12684
  }
12732
- function parseConcurrency(raw) {
12733
- if (raw === void 0) return DEFAULT_CONCURRENCY;
12734
- const n = Number.parseInt(raw, 10);
12735
- if (!Number.isFinite(n) || n < 1) {
12736
- error(`invalid --concurrency: ${raw} (expected positive integer)`);
12737
- process.exit(2);
12685
+ /** Per-step count of kept-but-replay-unstable actions (the flaky selectors). */
12686
+ function countUnstableByStep(actions) {
12687
+ const byStep = /* @__PURE__ */ new Map();
12688
+ for (const a of actions) {
12689
+ if (!a.stepId || !a.replayUnstable) continue;
12690
+ byStep.set(a.stepId, (byStep.get(a.stepId) ?? 0) + 1);
12738
12691
  }
12739
- return n;
12692
+ return byStep;
12693
+ }
12694
+ /**
12695
+ * One kept action as `action selector` (with the fill value or assert
12696
+ * type when present) — the canonical form the record playbook should reuse.
12697
+ * Unlike the live summary this keeps selectors verbatim: record learns
12698
+ * concrete per-spec selectors, so masking them would defeat the purpose.
12699
+ *
12700
+ * A replay-unstable action (kept in lenient mode but flagged because its
12701
+ * selector wasn't present on a fresh replay) is tagged `[unstable](<reason>)`
12702
+ * so the learner does NOT record its selector as canonical — the reason is the
12703
+ * teacher signal ("not present within Nms" = timing-fragile, not stable).
12704
+ */
12705
+ function formatRecordedAction(a) {
12706
+ const parts = [a.action];
12707
+ if (a.index !== void 0) parts.push(String(a.index));
12708
+ const anchor = a.locator ? formatLocator(a.locator) : a.label;
12709
+ if (anchor) parts.push(anchor);
12710
+ if (a.value) parts.push(`= ${a.value}`);
12711
+ if (a.assert) parts.push(`(${a.assert})`);
12712
+ const line = oneLineSummary(parts.join(" "));
12713
+ return a.replayUnstable ? `${line} [unstable](${oneLineSummary(a.replayReason ?? "no reason")})` : line;
12714
+ }
12715
+ /** Verbatim locator form: raw selector for css, `by=value` for semantic ones. */
12716
+ function formatLocator(locator) {
12717
+ const name = locator.by === "role" && locator.name ? ` name=${locator.name}` : "";
12718
+ return locator.by === "css" ? locator.value : `${locator.by}=${locator.value}${name}`;
12719
+ }
12720
+ function oneLineSummary(s) {
12721
+ const flat = s.replace(/\s+/g, " ").trim();
12722
+ return flat.length > 240 ? flat.slice(0, 240) + "…" : flat || "(none)";
12740
12723
  }
12741
12724
  //#endregion
12742
- //#region src/cli/init.ts
12743
- const initCommand = new Command("init").description("Create the .ccqa/ spec skeleton (features/, blocks/) in the current directory.").option("--cwd <path>", "Working directory (default: cwd)").action(async (opts) => {
12744
- const cwd = resolveCwd(opts.cwd);
12745
- header("init", cwd);
12746
- await ensureCcqaDir(cwd);
12747
- info("created .ccqa/features/, .ccqa/blocks/");
12748
- blank();
12749
- hint("set CCQA_HUB_URL / CCQA_HUB_TOKEN to connect to a ccqa hub");
12750
- hint("edit guidance prompts in the hub UI's Prompts tab");
12751
- hint("run `ccqa record <feature>/<spec>` to start recording");
12752
- });
12753
- //#endregion
12754
- //#region src/prompts/perspectives.ts
12725
+ //#region src/drift/format.ts
12755
12726
  /**
12756
- * Build the system prompt. By default the descriptive fields follow the
12757
- * spec's own language (Japanese specs Japanese fields). An explicit
12758
- * `--language` is applied by the CLI via `languageDirective`, appended to
12759
- * this prompt, so the language handling lives in one shared place.
12727
+ * Render drift results as a string. The CLI commands and the `run` failure
12728
+ * hook are the only callers; both want the formatted output returned so
12729
+ * they can prefix / interleave / pipe it as needed.
12760
12730
  */
12761
- function buildPerspectivesSystemPrompt() {
12762
- return `You produce a factual inventory of the E2E test coverage that already exists in a ccqa project.
12763
-
12764
- Think of it as a QA coverage stock-take: for each existing test case, fill in a few short, neutral descriptive fields derived from its steps. Nothing more.
12765
-
12766
- ## Hard boundaries (do NOT cross)
12767
-
12768
- - Do NOT assign severity, importance, priority, or risk. Whether a failure hurts the customer is a human + PdM decision; you are not authoring that here.
12769
- - Do NOT do gap analysis. Do NOT list untested areas, missing coverage, or things the code has but the tests lack.
12770
- - Do NOT evaluate whether the feature is good, complete, or correct.
12771
- - Do NOT propose new test cases.
12772
- - Do NOT restate the full step-by-step procedure or the per-step expected results — the spec.yaml is the source of truth for those and the inventory links to it.
12773
- - Do NOT touch status, relatedPaths, feature names, or spec names — the CLI already fixed those.
12774
-
12775
- ## Fields to write (per spec)
12776
-
12777
- - \`summary\`: 1–2 sentences, factual and neutral. What the test exercises and what it ultimately asserts, derived from the spec's \`steps\` (\`instruction\` / \`expected\`).
12778
- - \`startScreen\`: the screen/URL the test first lands on after setup (e.g. "Dashboard (/dashboard)"). Derive from the first non-login \`instruction\`. Omit if genuinely unclear.
12779
- - \`testCondition\`: the state/precondition the scenario assumes, phrased as a condition (e.g. "Logged in as an admin", "Unauthenticated user"). Omit if none.
12780
- - \`preconditions\`: array of short setup prerequisites (e.g. which role logs in, required prior state). Derive from \`include: login\` params and the opening steps. Empty/omit if none.
12781
-
12782
- ## How to write
12783
-
12784
- - Same language as the spec's title (if titles are Japanese, write these fields in Japanese).
12785
- - Keep each field short. These are index entries, not the test itself.
12786
- - You may use Read/Grep/Glob sparingly to clarify domain vocabulary, but the steps are the primary source. Do not over-explore.
12787
-
12788
- ## Output contract (STRICT)
12789
-
12790
- Output exactly ONE fenced \`\`\`json code block, and nothing else outside it. No prose before or after.
12791
-
12792
- Schema:
12793
-
12794
- \`\`\`json
12795
- {
12796
- "summaries": [
12797
- {
12798
- "featureName": "<verbatim from input>",
12799
- "specName": "<verbatim from input>",
12800
- "summary": "<1–2 sentence factual description of what this test verifies>",
12801
- "startScreen": "<opening screen/URL, or omit>",
12802
- "testCondition": "<assumed state phrased as a condition, or omit>",
12803
- "preconditions": ["<setup prerequisite>", "..."]
12804
- }
12805
- ]
12731
+ function renderDrift(results, format, cwd) {
12732
+ if (format === "json") return renderJson(results);
12733
+ if (format === "github") return renderGithub(results, cwd);
12734
+ return renderText(results);
12735
+ }
12736
+ const HEAVY_RULE = "═".repeat(72);
12737
+ function renderText(results) {
12738
+ const out = [];
12739
+ for (const r of results) {
12740
+ out.push("");
12741
+ const heading = `══ ${r.target.featureName}/${r.target.specName} `;
12742
+ const tail = "═".repeat(Math.max(3, 72 - heading.length));
12743
+ out.push(`${heading}${tail}`);
12744
+ if (r.error) {
12745
+ out.push(` ERROR ${r.error}`);
12746
+ continue;
12747
+ }
12748
+ const errors = r.issues.filter((i) => i.severity === "ERROR");
12749
+ const warnings = r.issues.filter((i) => i.severity === "WARN");
12750
+ const passed = r.issues.filter((i) => i.severity === "OK");
12751
+ if (errors.length === 0 && warnings.length === 0) {
12752
+ const label = passed.length === 1 ? "check" : "checks";
12753
+ const detail = passed.length > 0 ? `all ${passed.length} ${label} passed` : "no issues";
12754
+ out.push(` ✓ ${detail}`);
12755
+ continue;
12756
+ }
12757
+ for (const issue of errors) appendFinding(out, "ERROR", issue);
12758
+ for (const issue of warnings) appendFinding(out, "WARN", issue);
12759
+ if (passed.length > 0) {
12760
+ const names = passed.map((i) => DRAFT_CATEGORY_LABEL[i.category]).join(", ");
12761
+ out.push("");
12762
+ out.push(` ✓ passed (${passed.length}): ${names}`);
12763
+ }
12764
+ }
12765
+ out.push("");
12766
+ out.push(HEAVY_RULE);
12767
+ const totals = summarize(results);
12768
+ out.push(` specs ${results.length} (${totals.errored} errored)`);
12769
+ out.push(` findings ${totals.error} error, ${totals.warn} warn, ${totals.ok} ok`);
12770
+ out.push("");
12771
+ return out.join("\n");
12772
+ }
12773
+ function appendFinding(out, level, issue) {
12774
+ const stepPart = issue.stepId ? ` ${issue.stepId}` : "";
12775
+ out.push("");
12776
+ out.push(` ${level} ${DRAFT_CATEGORY_LABEL[issue.category]}${stepPart}`);
12777
+ out.push(` ${issue.message}`);
12778
+ if (issue.detail) out.push(` └ ${issue.detail.replace(/\n/g, "\n ")}`);
12779
+ }
12780
+ function renderJson(results) {
12781
+ const payload = { specs: results.map((r) => ({
12782
+ feature: r.target.featureName,
12783
+ spec: r.target.specName,
12784
+ ok: r.ok,
12785
+ ...r.error ? { error: r.error } : {},
12786
+ issues: r.issues.map((i) => ({
12787
+ severity: i.severity,
12788
+ category: i.category,
12789
+ stepId: i.stepId,
12790
+ message: i.message,
12791
+ ...i.detail ? { detail: i.detail } : {}
12792
+ }))
12793
+ })) };
12794
+ return `${JSON.stringify(payload, null, 2)}\n`;
12795
+ }
12796
+ function renderGithub(results, cwd) {
12797
+ const repoRoot = process.env["GITHUB_WORKSPACE"] ?? process.cwd();
12798
+ const lines = [];
12799
+ for (const r of results) {
12800
+ const file = githubRelPath(cwd, repoRoot, r.target.featureName, r.target.specName);
12801
+ if (r.error) {
12802
+ lines.push(`::error file=${file}::${escapeGhMessage(r.error)}`);
12803
+ continue;
12804
+ }
12805
+ for (const issue of r.issues) {
12806
+ if (issue.severity === "OK") continue;
12807
+ const level = issue.severity === "ERROR" ? "error" : "warning";
12808
+ const title = `${r.target.featureName}/${r.target.specName} — ${issue.category}${issue.stepId ? ` (${issue.stepId})` : ""}`;
12809
+ const body = issue.detail ? `${issue.message}\n${issue.detail}` : issue.message;
12810
+ lines.push(`::${level} file=${file},title=${escapeGhProp(title)}::${escapeGhMessage(body)}`);
12811
+ }
12812
+ }
12813
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
12806
12814
  }
12807
- \`\`\`
12808
-
12809
- Return one entry per spec given in the input. Echo featureName and specName verbatim so the CLI can match them. \`startScreen\`, \`testCondition\`, and \`preconditions\` are optional — omit a field (or use an empty array for preconditions) when the spec does not express it.
12810
- `;
12815
+ function githubRelPath(cwd, repoRoot, featureName, specName) {
12816
+ const abs = resolve(cwd, ".ccqa", "features", featureName, "test-cases", specName, "spec.yaml");
12817
+ const rel = relative(repoRoot, abs);
12818
+ return rel.startsWith("..") ? abs : rel;
12811
12819
  }
12812
- function buildPerspectivesPrompt(specs, instruction) {
12813
- return `## Existing test cases to summarise
12814
-
12815
- ${specs.map((s) => `### ${s.featureName}/${s.specName}
12816
- title: ${s.title}
12817
-
12818
- \`\`\`yaml
12819
- ${s.specYaml.trimEnd()}
12820
- \`\`\`
12821
- `).join("\n")}
12822
- ${instruction?.trim() ? `## Extra guidance from the user\n\n${instruction.trim()}\n\n` : ""}## Task
12823
-
12824
- For each test case above, write a 1–2 sentence factual \`summary\` of what it verifies, derived from its steps. Return one entry per spec in the JSON contract. Do not assign severity, do gap analysis, or invent new cases.
12825
- `;
12820
+ function escapeGhMessage(s) {
12821
+ return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
12826
12822
  }
12827
- //#endregion
12828
- //#region src/cli/perspectives.ts
12829
- const perspectivesCommand = addLanguageOption(new Command("perspectives").description("Generate/update .ccqa/perspectives.yaml — a factual inventory of existing test coverage (no severity, no gap analysis)").option("--instruction <text>", "Hint to steer how summaries are written").option("--apply", "Auto-apply without [y/N] confirmation", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID")).action(async (opts) => {
12830
- await runPerspectives(opts);
12831
- });
12832
- async function runPerspectives(opts) {
12833
- header("perspectives", ".ccqa/perspectives.yaml");
12834
- await ensureCcqaDir();
12835
- const skeleton = await buildSkeleton(await listFeatureTree());
12836
- const allSpecs = skeleton.flatMap((f) => f.specs);
12837
- if (allSpecs.length === 0) {
12838
- info("no test cases found under .ccqa/features nothing to inventory.");
12839
- return;
12840
- }
12841
- const existingRaw = await tryReadPerspectives() ?? "";
12842
- const noteMap = extractNotes(existingRaw);
12843
- const specBodies = await loadSpecBodies(skeleton);
12844
- meta("language", opts.language ?? "auto");
12845
- info(`Summarising ${allSpecs.length} test case(s) across ${skeleton.length} feature(s)...`);
12846
- const summaries = await requestSummaries(specBodies, opts);
12847
- if (summaries === null) process.exit(1);
12848
- const merged = mergePerspectives(skeleton, summaries, noteMap);
12849
- let validated;
12850
- try {
12851
- validated = PerspectivesSchema.parse(merged);
12852
- } catch (e) {
12853
- error(`refused to write: assembled perspectives failed validation (${e.message})`);
12854
- process.exit(1);
12855
- }
12856
- const next = stringify(validated, { lineWidth: 0 });
12857
- if (withoutGeneratedAt(existingRaw) === withoutGeneratedAt(next)) {
12858
- blank();
12859
- info("perspectives already up to date — no changes.");
12860
- return;
12861
- }
12862
- blank();
12863
- info("--- proposed changes (perspectives.yaml) ---");
12864
- printUnifiedDiff(existingRaw, next);
12865
- blank();
12866
- if (!(opts.apply === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "perspectives.yaml + .md を書き込みますか? [y/N] " : "Write perspectives.yaml + .md? [y/N] ")))) {
12867
- info("aborted — no changes written.");
12868
- return;
12823
+ function escapeGhProp(s) {
12824
+ return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/,/g, "%2C").replace(/:/g, "%3A");
12825
+ }
12826
+ function summarize(results) {
12827
+ let error = 0;
12828
+ let warn = 0;
12829
+ let ok = 0;
12830
+ let errored = 0;
12831
+ for (const r of results) {
12832
+ if (r.error) errored++;
12833
+ for (const issue of r.issues) if (issue.severity === "ERROR") error++;
12834
+ else if (issue.severity === "WARN") warn++;
12835
+ else ok++;
12869
12836
  }
12870
- meta("saved", await savePerspectives(next));
12871
- const labels = labelsFor(opts.language);
12872
- meta("saved", await savePerspectivesMarkdown(renderIndexMarkdown(validated, labels)));
12873
- for (const feature of validated.features) meta("saved", await saveFeaturePerspectivesMarkdown(feature.featureName, renderFeatureMarkdown(feature, labels)));
12837
+ return {
12838
+ error,
12839
+ warn,
12840
+ ok,
12841
+ errored
12842
+ };
12874
12843
  }
12844
+ //#endregion
12845
+ //#region src/drift/exit-code.ts
12875
12846
  /**
12876
- * Turn the feature tree into the skeleton perspectives features: title +
12877
- * relatedPaths transcribed from each spec, status derived mechanically from
12878
- * on-disk artifacts. `summary` is left empty here; Claude fills it later.
12879
- * Specs whose spec.yaml is missing or unparsable are skipped.
12847
+ * Map drift results to an exit code. Spec-level errors (Claude call failed)
12848
+ * always fail; otherwise ERROR severity always fails, WARN fails only when
12849
+ * the threshold is `warn`.
12880
12850
  */
12881
- async function buildSkeleton(tree) {
12882
- return (await Promise.all(tree.map(async (feature) => {
12883
- const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
12884
- const spec = await readSpecMeta(feature.featureName, s.specName);
12885
- const status = await deriveStatus(feature.featureName, s.specName, spec.mode);
12886
- const entry = {
12887
- specName: s.specName,
12888
- title: spec.title,
12889
- summary: "",
12890
- status
12891
- };
12892
- if (s.relatedPaths) entry.relatedPaths = s.relatedPaths;
12893
- return entry;
12894
- }));
12895
- return {
12896
- featureName: feature.featureName,
12897
- specs
12898
- };
12899
- }))).filter((f) => f.specs.length > 0).map((f) => ({
12900
- featureName: f.featureName,
12901
- specs: [...f.specs].sort((a, b) => a.specName.localeCompare(b.specName))
12902
- })).sort((a, b) => a.featureName.localeCompare(b.featureName));
12851
+ function determineExitCode(results, threshold) {
12852
+ for (const r of results) {
12853
+ if (r.error) return 1;
12854
+ for (const issue of r.issues) {
12855
+ if (issue.severity === "ERROR") return 1;
12856
+ if (threshold === "warn" && issue.severity === "WARN") return 1;
12857
+ }
12858
+ }
12859
+ return 0;
12903
12860
  }
12904
12861
  /**
12905
- * `(featureName, specName)` human note, parsed from an existing
12906
- * perspectives.yaml. Notes are preserved across regeneration; everything
12907
- * else (title, status, summary) is recomputed. Returns an empty map when the
12908
- * input is empty or unparsable — note preservation is best-effort and never
12909
- * blocks regeneration.
12862
+ * Spec-level status under the given threshold, mirroring determineExitCode's
12863
+ * per-issue logic (exit-code.ts) but scoped to a single SpecResult.
12910
12864
  */
12911
- function extractNotes(existingRaw) {
12912
- const map = /* @__PURE__ */ new Map();
12913
- if (!existingRaw.trim()) return map;
12914
- let parsed;
12915
- try {
12916
- parsed = parse(existingRaw);
12917
- } catch {
12918
- return map;
12865
+ function specStatus(result, threshold) {
12866
+ if (result.error) return "failed";
12867
+ for (const issue of result.issues) {
12868
+ if (issue.severity === "ERROR") return "failed";
12869
+ if (threshold === "warn" && issue.severity === "WARN") return "failed";
12919
12870
  }
12920
- const result = PerspectivesSchema.safeParse(parsed);
12921
- if (!result.success) return map;
12922
- for (const feature of result.data.features) for (const spec of feature.specs) if (spec.note !== void 0 && spec.note !== "") map.set(noteKey(feature.featureName, spec.specName), spec.note);
12923
- return map;
12871
+ return "passed";
12924
12872
  }
12925
12873
  /**
12926
- * Merge the mechanical skeleton with Claude's summaries and the preserved
12927
- * notes into the final perspectives object. Summaries are matched by
12928
- * (featureName, specName); an unmatched spec keeps its empty summary.
12874
+ * Adapts `ccqa drift` results into the shared RunReportData shape so they can
12875
+ * be pushed to the hub (`ccqa drift --push`) and rendered by the same report
12876
+ * UI as `ccqa run`/`ccqa live`. Browser-execution fields (testCounts,
12877
+ * evidence, liveRun, ...) don't apply to a drift audit and are always null.
12929
12878
  */
12930
- function mergePerspectives(skeleton, summaries, noteMap) {
12931
- const summaryMap = /* @__PURE__ */ new Map();
12932
- for (const s of summaries) summaryMap.set(noteKey(s.featureName, s.specName), s);
12933
- const features = skeleton.map((feature) => ({
12934
- featureName: feature.featureName,
12935
- specs: feature.specs.map((spec) => {
12936
- const key = noteKey(feature.featureName, spec.specName);
12937
- const entry = summaryMap.get(key);
12938
- const merged = {
12939
- ...spec,
12940
- summary: entry?.summary ?? spec.summary
12941
- };
12942
- if (entry?.startScreen) merged.startScreen = entry.startScreen;
12943
- if (entry?.testCondition) merged.testCondition = entry.testCondition;
12944
- if (entry?.preconditions && entry.preconditions.length > 0) merged.preconditions = entry.preconditions;
12945
- const note = noteMap.get(key);
12946
- if (note !== void 0) merged.note = note;
12947
- return merged;
12948
- })
12879
+ function driftResultsToReport(results, meta) {
12880
+ const specResults = results.map((result) => ({
12881
+ feature: result.target.featureName,
12882
+ spec: result.target.specName,
12883
+ title: null,
12884
+ status: specStatus(result, meta.threshold),
12885
+ testCounts: null,
12886
+ durationMs: null,
12887
+ assertions: null,
12888
+ analysis: null,
12889
+ analysisSkipped: null,
12890
+ driftIssues: result.issues,
12891
+ failureLogExcerpt: null,
12892
+ diffExcerpt: null,
12893
+ specYaml: null,
12894
+ evidence: null,
12895
+ liveRun: null
12949
12896
  }));
12950
12897
  return {
12951
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12952
- features
12898
+ schemaVersion: 1,
12899
+ kind: "drift",
12900
+ createdAt: meta.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
12901
+ runId: meta.runId ?? null,
12902
+ git: meta.git,
12903
+ model: meta.model ?? null,
12904
+ language: meta.language ?? null,
12905
+ promptVersion: meta.promptVersion ?? "1",
12906
+ customPromptVersion: null,
12907
+ results: specResults
12953
12908
  };
12954
12909
  }
12910
+ //#endregion
12911
+ //#region src/drift/route-new-files.ts
12955
12912
  /**
12956
- * Strip the top-level `generatedAt:` line so two serialised perspectives can
12957
- * be compared for substantive equality without the always-fresh timestamp
12958
- * defeating the "already up to date" check. Exported for unit testing.
12913
+ * Lightweight Claude call: given a list of new files in the PR and the existing
12914
+ * specs (with their relatedPaths globs as a hint), return the spec keys (in
12915
+ * "<feature>/<spec>" form) that the new files plausibly affect.
12916
+ *
12917
+ * Conservative by design — false positives are safer than false negatives,
12918
+ * because a missed spec turns into undetected drift in CI. When the router
12919
+ * call itself fails, we log a warning rather than fail-close: the surrounding
12920
+ * glob match is the primary signal; the router only adds coverage for new
12921
+ * paths no glob captures.
12959
12922
  */
12960
- function withoutGeneratedAt(yamlText) {
12961
- return yamlText.split("\n").filter((line) => !/^generatedAt:/.test(line)).join("\n").trim();
12962
- }
12963
- function noteKey(featureName, specName) {
12964
- return `${featureName}/${specName}`;
12965
- }
12966
- async function readSpecMeta(featureName, specName) {
12967
- const raw = await tryReadSpecFile(featureName, specName);
12968
- if (raw === null) return {
12969
- title: specName,
12970
- mode: DEFAULT_SPEC_MODE
12971
- };
12972
- try {
12973
- const parsed = parse(raw);
12974
- const title = typeof parsed.title === "string" && parsed.title.length > 0 ? parsed.title : specName;
12975
- const modeResult = SpecModeSchema.safeParse(parsed.mode);
12976
- return {
12977
- title,
12978
- mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
12979
- };
12980
- } catch {
12981
- return {
12982
- title: specName,
12983
- mode: DEFAULT_SPEC_MODE
12984
- };
12985
- }
12986
- }
12987
- async function deriveStatus(featureName, specName, mode) {
12988
- return {
12989
- mode,
12990
- traced: await stat(join(getSpecDir(featureName, specName), "ir.json")).then(() => true).catch(() => false),
12991
- generated: await getTestScript(featureName, specName) !== null
12992
- };
12993
- }
12994
- async function loadSpecBodies(skeleton) {
12995
- return await Promise.all(skeleton.flatMap((feature) => feature.specs.map(async (spec) => {
12996
- const specYaml = await tryReadSpecFile(feature.featureName, spec.specName) ?? "";
12997
- return {
12998
- featureName: feature.featureName,
12999
- specName: spec.specName,
13000
- title: spec.title,
13001
- specYaml
13002
- };
13003
- })));
13004
- }
13005
- async function requestSummaries(specs, opts) {
13006
- const toolCounts = {};
13007
- const startedAt = Date.now();
12923
+ async function routeNewFilesToSpecs(input) {
12924
+ const { newFiles, specs, cwd, model } = input;
12925
+ const empty = /* @__PURE__ */ new Set();
12926
+ if (newFiles.length === 0 || specs.length === 0) return empty;
13008
12927
  const { result, isError } = await invokeClaudeStreaming({
13009
- prompt: buildPerspectivesPrompt(specs, opts.instruction),
13010
- systemPrompt: buildPerspectivesSystemPrompt() + languageDirective(opts.language),
12928
+ prompt: buildRouterPrompt(await Promise.all(newFiles.map(async (path) => ({
12929
+ path,
12930
+ head: await readHead(join(cwd, path))
12931
+ }))), specs),
12932
+ systemPrompt: buildRouterSystemPrompt(),
13011
12933
  allowedTools: [
13012
12934
  "Read",
13013
12935
  "Grep",
13014
12936
  "Glob"
13015
12937
  ],
13016
12938
  silenceBashLog: true,
13017
- ...opts.model ? { model: opts.model } : {}
13018
- }, (msg) => {
13019
- if (msg.type !== "assistant") return;
13020
- for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
13021
- });
13022
- process.stdout.write(`${formatToolSummary(toolCounts, Date.now() - startedAt)}\n`);
12939
+ cwd,
12940
+ ...model ? { model } : {}
12941
+ }, (_msg) => {});
13023
12942
  if (isError) {
13024
- error("Claude returned an error result");
13025
- return null;
12943
+ warn("new-file router: Claude returned an error; skipping router signal");
12944
+ return empty;
13026
12945
  }
13027
12946
  const json = extractJsonBlock(result);
13028
12947
  if (!json) {
13029
- error("Claude did not return a json block");
13030
- return null;
12948
+ warn("new-file router: no JSON block in response; skipping router signal");
12949
+ return empty;
13031
12950
  }
13032
- return parseSummaries(json);
12951
+ let parsed;
12952
+ try {
12953
+ parsed = JSON.parse(json);
12954
+ } catch (e) {
12955
+ warn(`new-file router: failed to parse JSON (${e.message}); skipping router signal`);
12956
+ return empty;
12957
+ }
12958
+ const out = /* @__PURE__ */ new Set();
12959
+ const validKeys = new Set(specs.map((s) => `${s.featureName}/${s.specName}`));
12960
+ if (typeof parsed === "object" && parsed !== null && "affectedSpecs" in parsed) {
12961
+ const arr = parsed.affectedSpecs;
12962
+ if (Array.isArray(arr)) {
12963
+ for (const item of arr) if (typeof item === "string" && validKeys.has(item)) out.add(item);
12964
+ }
12965
+ }
12966
+ return out;
12967
+ }
12968
+ async function readHead(absPath) {
12969
+ const content = await readFile(absPath, "utf-8").catch(() => "");
12970
+ if (!content) return "";
12971
+ return content.split("\n").slice(0, 40).join("\n");
12972
+ }
12973
+ function buildRouterSystemPrompt() {
12974
+ return `You triage which ccqa test specs are potentially affected by NEW source files added in a pull request.
12975
+
12976
+ You will receive:
12977
+ - A list of new files (path + first ~40 lines of each)
12978
+ - A list of existing specs with their declared relatedPaths globs
12979
+
12980
+ Your job: return the spec keys (in "<feature>/<spec>" form) whose behaviour might depend on any of the new files.
12981
+
12982
+ ## Rules
12983
+
12984
+ - Be **conservative**: when in doubt, include the spec. A spurious inclusion costs one extra drift check; a missed spec lets real drift slip through CI.
12985
+ - Use \`Read\`, \`Grep\`, \`Glob\` if you need to inspect the spec body or related code, but stay focused — this is a triage step, not a full review.
12986
+ - Ignore specs whose relatedPaths clearly point to a different area than every new file (e.g. \`src/auth/**\` specs vs new files only under \`src/billing/**\`).
12987
+ - Files like tests, generated code, build artifacts, vendor dirs typically do not affect any spec. Skip them.
12988
+
12989
+ ## Output (STRICT)
12990
+
12991
+ Output ONE fenced \`\`\`json block, nothing else:
12992
+
12993
+ \`\`\`json
12994
+ {
12995
+ "affectedSpecs": ["feature/spec", "feature/spec"]
12996
+ }
12997
+ \`\`\`
12998
+
12999
+ Use exactly the keys you saw in the input ("<feature>/<spec>"). Return an empty array if no spec is affected.
13000
+ `;
13001
+ }
13002
+ function buildRouterPrompt(previews, specs) {
13003
+ return `## New files
13004
+
13005
+ ${previews.map((p) => {
13006
+ const headBlock = p.head ? `\n\`\`\`\n${p.head}\n\`\`\`` : "\n(empty or unreadable)";
13007
+ return `### ${p.path}${headBlock}`;
13008
+ }).join("\n\n")}
13009
+
13010
+ ## Existing specs
13011
+
13012
+ ${specs.map((s) => {
13013
+ const paths = s.relatedPaths.length === 0 ? " (no relatedPaths declared)" : s.relatedPaths.map((p) => ` - ${p}`).join("\n");
13014
+ return `- ${s.featureName}/${s.specName}\n${paths}`;
13015
+ }).join("\n")}
13016
+
13017
+ ## Task
13018
+
13019
+ Return the spec keys that might be affected by any of the new files. Conservative inclusion is preferred over missing real drift.
13020
+ `;
13033
13021
  }
13022
+ //#endregion
13023
+ //#region src/cli/drift.ts
13024
+ const DEFAULT_CONCURRENCY = 3;
13025
+ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Standalone spec ↔ codebase static audit. Use for PR checks where the browser isn't run. For run-time audit with a structured report, see `ccqa run --report`.").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). New files are routed to specs via a single lightweight Claude call.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI) or origin/main.").option("--push", "Push the drift result to a ccqa hub as a run (kind: drift).").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption)).action(async (specPath, opts) => {
13026
+ const format = parseFormat(opts.format);
13027
+ const threshold = parseSeverity(opts.severity);
13028
+ const concurrency = parseConcurrency(opts.concurrency);
13029
+ const cwd = resolveCwd(opts.cwd);
13030
+ await ensureCcqaDir(cwd);
13031
+ if (opts.changed && specPath) {
13032
+ error("--changed and an explicit spec id cannot be combined; --changed only applies to a full sweep");
13033
+ process.exit(2);
13034
+ }
13035
+ let targets = await collectTargets(specPath, cwd);
13036
+ if (targets.length === 0) exitWithNoSpecs(format, "no test specs found under .ccqa/features/");
13037
+ if (format === "text") {
13038
+ header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
13039
+ if (opts.cwd) meta("cwd", cwd);
13040
+ }
13041
+ const baseRef = opts.changed ? resolveBaseRef(opts.base) : null;
13042
+ if (opts.changed) {
13043
+ const total = targets.length;
13044
+ targets = await filterByChanged({
13045
+ targets,
13046
+ cwd,
13047
+ baseOverride: opts.base,
13048
+ format,
13049
+ model: opts.model
13050
+ });
13051
+ if (format === "text") meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
13052
+ if (targets.length === 0) exitWithNoSpecs(format, "no specs intersect the changed file set; nothing to check");
13053
+ }
13054
+ const blocks = await loadAvailableBlocks(cwd);
13055
+ const results = await analyzeDrift({
13056
+ targets,
13057
+ cwd,
13058
+ blocks,
13059
+ concurrency,
13060
+ ...opts.model ? { model: opts.model } : {},
13061
+ ...opts.language ? { language: opts.language } : {},
13062
+ onSpecStart: (t) => {
13063
+ if (format === "text") info(`checking ${t.featureName}/${t.specName}`);
13064
+ }
13065
+ });
13066
+ process.stdout.write(renderDrift(results, format, cwd));
13067
+ if (opts.push) await pushDriftResults({
13068
+ results,
13069
+ threshold,
13070
+ cwd,
13071
+ opts,
13072
+ format,
13073
+ baseRef
13074
+ });
13075
+ process.exit(determineExitCode(results, threshold));
13076
+ });
13034
13077
  /**
13035
- * Parse the `{ summaries: [...] }` JSON contract into typed entries. Returns
13036
- * null and logs when the payload is malformed. Exported for unit testing.
13078
+ * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
13079
+ * shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
13080
+ * hub connection warns and returns rather than failing the command (`--push`
13081
+ * never changes drift's own exit code).
13082
+ *
13083
+ * `resolveHub` is injectable so tests can supply a fake `HubClient` without
13084
+ * a real hub connection; it defaults to the real flag/env resolution.
13037
13085
  */
13038
- function parseSummaries(json) {
13039
- let payload;
13086
+ async function pushDriftResults(args, resolveHub = resolveHubClient) {
13087
+ const { results, threshold, cwd, opts, format, baseRef } = args;
13088
+ const hub = resolveHub(opts);
13089
+ if (!hub) {
13090
+ warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
13091
+ return;
13092
+ }
13040
13093
  try {
13041
- payload = JSON.parse(json);
13094
+ const project = resolveProject({
13095
+ project: opts.project,
13096
+ cwd
13097
+ });
13098
+ const [branch, head] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
13099
+ const report = driftResultsToReport(results, {
13100
+ threshold,
13101
+ git: {
13102
+ head,
13103
+ base: baseRef ?? null
13104
+ }
13105
+ });
13106
+ const dir = await mkdtemp(join(tmpdir(), "ccqa-drift-push-"));
13107
+ try {
13108
+ await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2), "utf8");
13109
+ const archive = await packDirToTarGz(dir);
13110
+ const run = await hub.pushRun(archive, {
13111
+ project,
13112
+ ...branch ? { branch } : {},
13113
+ kind: "drift"
13114
+ });
13115
+ if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${run.id}`);
13116
+ } finally {
13117
+ await rm(dir, {
13118
+ recursive: true,
13119
+ force: true
13120
+ });
13121
+ }
13122
+ } catch (err) {
13123
+ if (err instanceof HubApiError) {
13124
+ error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
13125
+ process.exit(2);
13126
+ }
13127
+ throw err;
13128
+ }
13129
+ }
13130
+ function exitWithNoSpecs(format, message) {
13131
+ if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
13132
+ else if (format === "text") info(message);
13133
+ process.exit(0);
13134
+ }
13135
+ async function filterByChanged(input) {
13136
+ const { targets, cwd, baseOverride, format, model } = input;
13137
+ const base = resolveBaseRef(baseOverride);
13138
+ let changed;
13139
+ try {
13140
+ changed = await getChangedFiles(base, cwd);
13042
13141
  } catch (e) {
13043
- error(`failed to parse summaries JSON: ${e.message}`);
13044
- return null;
13142
+ error(`failed to run 'git diff' against ${base}: ${e.message}`);
13143
+ process.exit(2);
13045
13144
  }
13046
- if (typeof payload !== "object" || payload === null) {
13047
- error("summaries payload is not an object");
13048
- return null;
13145
+ if (format === "text") {
13146
+ meta("changed-base", base);
13147
+ meta("changed-files", changed.length);
13148
+ }
13149
+ if (changed.length === 0) return [];
13150
+ const newFiles = changed.filter((f) => f.status === "added" && !f.outsideCwd);
13151
+ const existingChanges = changed.filter((f) => f.status !== "added" || f.outsideCwd);
13152
+ const affected = /* @__PURE__ */ new Set();
13153
+ const touchedBlockNames = /* @__PURE__ */ new Set();
13154
+ for (const f of changed) {
13155
+ if (f.outsideCwd) continue;
13156
+ const blockName = parseBlockPath(f.path);
13157
+ if (blockName) touchedBlockNames.add(blockName);
13158
+ }
13159
+ for (const t of targets) {
13160
+ if (!t.relatedPaths) {
13161
+ affected.add(specKey(t));
13162
+ continue;
13163
+ }
13164
+ if (existingChanges.some((f) => isPathAffectedBy(f.path, t.relatedPaths)) || newFiles.some((f) => isPathAffectedBy(f.path, t.relatedPaths))) {
13165
+ affected.add(specKey(t));
13166
+ continue;
13167
+ }
13168
+ if (t.includedBlocks?.some((name) => touchedBlockNames.has(name))) affected.add(specKey(t));
13169
+ }
13170
+ if (newFiles.length > 0) {
13171
+ if (format === "text") info(`routing ${newFiles.length} new file(s) to specs via Claude...`);
13172
+ const routed = await routeNewFilesToSpecs({
13173
+ newFiles: newFiles.map((f) => f.path),
13174
+ specs: targets.filter((t) => t.relatedPaths).map((t) => ({
13175
+ featureName: t.featureName,
13176
+ specName: t.specName,
13177
+ relatedPaths: t.relatedPaths
13178
+ })),
13179
+ cwd,
13180
+ model
13181
+ });
13182
+ for (const key of routed) affected.add(key);
13049
13183
  }
13050
- const summaries = payload.summaries;
13051
- if (!Array.isArray(summaries)) {
13052
- error("summaries payload missing a `summaries` array");
13053
- return null;
13184
+ return targets.filter((t) => affected.has(specKey(t)));
13185
+ }
13186
+ async function collectTargets(specPath, cwd) {
13187
+ const tree = await listFeatureTree(cwd);
13188
+ if (specPath) {
13189
+ const { featureName, specName } = parseSpecPath(specPath);
13190
+ const spec = tree.find((f) => f.featureName === featureName)?.specs.find((s) => s.specName === specName);
13191
+ if (!spec?.hasSpecFile) {
13192
+ error(`spec not found: ${featureName}/${specName} (under ${cwd})`);
13193
+ process.exit(1);
13194
+ }
13195
+ return [{
13196
+ featureName,
13197
+ specName,
13198
+ includedBlocks: spec.includedBlocks ?? []
13199
+ }];
13054
13200
  }
13055
13201
  const out = [];
13056
- for (const item of summaries) {
13057
- const rec = item ?? {};
13058
- const { featureName, specName, summary } = rec;
13059
- if (typeof featureName === "string" && typeof specName === "string" && typeof summary === "string") {
13060
- const entry = {
13061
- featureName,
13062
- specName,
13063
- summary
13064
- };
13065
- if (typeof rec.startScreen === "string" && rec.startScreen.length > 0) entry.startScreen = rec.startScreen;
13066
- if (typeof rec.testCondition === "string" && rec.testCondition.length > 0) entry.testCondition = rec.testCondition;
13067
- if (Array.isArray(rec.preconditions)) {
13068
- const pre = rec.preconditions.filter((p) => typeof p === "string" && p.length > 0);
13069
- if (pre.length > 0) entry.preconditions = pre;
13070
- }
13071
- out.push(entry);
13072
- }
13202
+ for (const feature of tree) for (const spec of feature.specs) {
13203
+ if (!spec.hasSpecFile) continue;
13204
+ const t = {
13205
+ featureName: feature.featureName,
13206
+ specName: spec.specName
13207
+ };
13208
+ if (spec.relatedPaths) t.relatedPaths = spec.relatedPaths;
13209
+ if (spec.includedBlocks) t.includedBlocks = spec.includedBlocks;
13210
+ out.push(t);
13073
13211
  }
13074
13212
  return out;
13075
13213
  }
13076
- const LABELS_JA = {
13077
- indexTitle: "テスト観点インデックス (perspectives)",
13078
- caseCol: "ケース",
13079
- itemCol: "項目",
13080
- valueCol: "内容",
13081
- modeCol: "モード",
13082
- statusCol: "状態",
13083
- modeLabel: "モード",
13084
- summary: "検証内容",
13085
- preconditions: "前提条件",
13086
- startScreen: "開始画面",
13087
- relatedCode: "関連コード",
13088
- modeDeterministic: "deterministic",
13089
- modeLive: "live",
13090
- notRunnable: "⚠️ 未record",
13091
- runnable: "✅ 実行可能"
13092
- };
13093
- const LABELS_EN = {
13094
- indexTitle: "Test Perspectives (perspectives)",
13095
- caseCol: "Case",
13096
- itemCol: "Item",
13097
- valueCol: "Value",
13098
- modeCol: "Mode",
13099
- statusCol: "Status",
13100
- modeLabel: "Mode",
13101
- summary: "Verifies",
13102
- preconditions: "Preconditions",
13103
- startScreen: "Start screen",
13104
- relatedCode: "Related code",
13105
- modeDeterministic: "deterministic",
13106
- modeLive: "live",
13107
- notRunnable: "⚠️ not recorded",
13108
- runnable: "✅ runnable"
13109
- };
13110
- /**
13111
- * Pick the label set for a `--language` value. Only an explicit English tag
13112
- * (`en`, `en-US`, …) switches to English labels; `auto`, `ja`, and anything
13113
- * else keep Japanese, matching the source-following default the rest of the
13114
- * command uses.
13115
- */
13116
- function labelsFor(language) {
13117
- return /^en\b/i.test(language?.trim() ?? "") ? LABELS_EN : LABELS_JA;
13118
- }
13119
- /**
13120
- * Whether the spec is runnable from the reviewer's perspective. Live specs
13121
- * are always runnable (no codegen step); a deterministic spec is runnable
13122
- * only once `test.spec.ts` exists.
13123
- */
13124
- function statusLabel(status, labels) {
13125
- if (status.mode === "live") return labels.runnable;
13126
- return status.generated ? labels.runnable : labels.notRunnable;
13127
- }
13128
- /** The spec's execution mode (deterministic or live), per spec.yaml. */
13129
- function modeLabel(status, labels) {
13130
- return status.mode === "live" ? labels.modeLive : labels.modeDeterministic;
13131
- }
13132
- /**
13133
- * Path to a spec.yaml relative to the **root** `.ccqa/perspectives.md`
13134
- * (i.e. relative to the `.ccqa/` dir). Used for the category index links.
13135
- */
13136
- function specRelPathFromRoot(featureName, specName) {
13137
- return `features/${featureName}/test-cases/${specName}/spec.yaml`;
13138
- }
13139
- /**
13140
- * Path to a category detail file relative to the **root** `.ccqa/perspectives.md`.
13141
- * The detail file is written to `.ccqa/features/<feature>/perspectives.md`
13142
- * (see `getFeaturePerspectivesMarkdownPath`), so the link must include the
13143
- * `features/` segment — otherwise the category heading link 404s.
13144
- */
13145
- function featureDetailRelPathFromRoot(featureName) {
13146
- return `features/${featureName}/perspectives.md`;
13214
+ function parseFormat(raw) {
13215
+ const v = raw ?? "text";
13216
+ if (v === "text" || v === "json" || v === "github") return v;
13217
+ error(`invalid --format: ${v} (expected text|json|github)`);
13218
+ process.exit(2);
13147
13219
  }
13148
- /**
13149
- * Path to a spec.yaml relative to the **category** detail file
13150
- * `.ccqa/features/<feature>/perspectives.md`. The spec lives alongside under
13151
- * `test-cases/<spec>/`, so the category file links to it directly — which is
13152
- * what makes the link resolve both on GitHub and in a local editor.
13153
- */
13154
- function specRelPathFromCategory(specName) {
13155
- return `test-cases/${specName}/spec.yaml`;
13220
+ function parseSeverity(raw) {
13221
+ const v = raw ?? "error";
13222
+ if (v === "warn" || v === "error") return v;
13223
+ error(`invalid --severity: ${v} (expected warn|error)`);
13224
+ process.exit(2);
13156
13225
  }
13157
- /**
13158
- * Render the root `.ccqa/perspectives.md`: a category-grouped index of which
13159
- * cases exist. Each feature is a heading (linking to its own detail
13160
- * `perspectives.md`) followed by a row per case — title, status, and a link
13161
- * to that case's spec.yaml. The per-case *detail* (検証内容, preconditions,
13162
- * note) still lives only in the per-category file; the root stays a scannable
13163
- * "what is tested, and where" overview.
13164
- *
13165
- * Pure and deterministic, so the index rendering is easy to unit-test.
13166
- */
13167
- function renderIndexMarkdown(perspectives, labels = LABELS_JA) {
13168
- const lines = [];
13169
- lines.push(`# ${labels.indexTitle}`);
13170
- lines.push("");
13171
- for (const feature of perspectives.features) {
13172
- const detailLink = featureDetailRelPathFromRoot(feature.featureName);
13173
- lines.push(`## [${feature.featureName}](${detailLink})`);
13174
- lines.push("");
13175
- lines.push(`| ${labels.caseCol} | ${labels.modeCol} | ${labels.statusCol} | spec |`);
13176
- lines.push("| --- | --- | --- | --- |");
13177
- for (const spec of feature.specs) {
13178
- const specLink = specRelPathFromRoot(feature.featureName, spec.specName);
13179
- const mode = mdCell(modeLabel(spec.status, labels));
13180
- const status = mdCell(statusLabel(spec.status, labels));
13181
- lines.push(`| ${mdCell(spec.title)} | ${mode} | ${status} | [spec](${specLink}) |`);
13182
- }
13183
- lines.push("");
13226
+ function parseConcurrency(raw) {
13227
+ if (raw === void 0) return DEFAULT_CONCURRENCY;
13228
+ const n = Number.parseInt(raw, 10);
13229
+ if (!Number.isFinite(n) || n < 1) {
13230
+ error(`invalid --concurrency: ${raw} (expected positive integer)`);
13231
+ process.exit(2);
13184
13232
  }
13185
- return lines.join("\n");
13186
- }
13187
- /**
13188
- * Render one category's `.ccqa/features/<feature>/perspectives.md`: every
13189
- * case in the category as a self-contained vertical table. All columns —
13190
- * including the verification summary (検証内容) and the human note — live
13191
- * inside the table; nothing is emitted outside it. Detailed steps / expected
13192
- * results are still not restated (the spec.yaml is their single home); the
13193
- * table links back to each spec instead.
13194
- *
13195
- * Pure and deterministic, so the per-case rendering is easy to unit-test.
13196
- */
13197
- function renderFeatureMarkdown(feature, labels = LABELS_JA) {
13198
- const lines = [];
13199
- lines.push(`# ${feature.featureName}`);
13200
- lines.push("");
13201
- for (const spec of feature.specs) lines.push(...renderSpecMarkdown(spec, labels));
13202
- return lines.join("\n");
13203
- }
13204
- /**
13205
- * Render one spec as a single vertical (item | content) Markdown table for a
13206
- * category file. Verification summary and preconditions lead. The spec link
13207
- * is relative to this category file so it resolves both on GitHub and in a
13208
- * local editor. Related-code paths stay inline code rather than links: their
13209
- * base (the cwd that hosts `.ccqa/`) is not reliably recoverable here — specs
13210
- * carry a mix of cwd-relative (`src/...`) and repo-root (`pkg/app/src/...`)
13211
- * forms — and many are globs that no link could open anyway. 検証内容
13212
- * (summary) and note are rows inside the table; no prose blocks are emitted
13213
- * around it. Exported for focused unit testing.
13214
- */
13215
- function renderSpecMarkdown(spec, labels = LABELS_JA) {
13216
- const lines = [];
13217
- lines.push(`## ${spec.title}`);
13218
- lines.push("");
13219
- lines.push(`| ${labels.itemCol} | ${labels.valueCol} |`);
13220
- lines.push("| --- | --- |");
13221
- if (spec.summary) lines.push(`| ${labels.summary} | ${mdCell(spec.summary)} |`);
13222
- if (spec.preconditions && spec.preconditions.length > 0) lines.push(`| ${labels.preconditions} | ${spec.preconditions.map(mdCell).join("<br>")} |`);
13223
- if (spec.startScreen) lines.push(`| ${labels.startScreen} | ${mdCell(spec.startScreen)} |`);
13224
- const specPath = specRelPathFromCategory(spec.specName);
13225
- lines.push(`| spec | [${specPath}](${specPath}) |`);
13226
- lines.push(`| ${labels.modeLabel} | ${mdCell(modeLabel(spec.status, labels))} |`);
13227
- lines.push(`| ${labels.statusCol} | ${mdCell(statusLabel(spec.status, labels))} |`);
13228
- if (spec.relatedPaths && spec.relatedPaths.length > 0) lines.push(`| ${labels.relatedCode} | ${spec.relatedPaths.map((p) => `\`${p}\``).join("<br>")} |`);
13229
- if (spec.note) lines.push(`| 📝 note | ${mdCell(spec.note)} |`);
13230
- lines.push("");
13231
- return lines;
13232
- }
13233
- /** Escape pipes / newlines so a value stays inside one Markdown table cell. */
13234
- function mdCell(value) {
13235
- return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
13233
+ return n;
13236
13234
  }
13237
13235
  //#endregion
13236
+ //#region src/cli/init.ts
13237
+ const initCommand = new Command("init").description("Create the .ccqa/ spec skeleton (features/, blocks/) in the current directory.").option("--cwd <path>", "Working directory (default: cwd)").action(async (opts) => {
13238
+ const cwd = resolveCwd(opts.cwd);
13239
+ header("init", cwd);
13240
+ await ensureCcqaDir(cwd);
13241
+ info("created .ccqa/features/, .ccqa/blocks/");
13242
+ blank();
13243
+ hint("set CCQA_HUB_URL / CCQA_HUB_TOKEN to connect to a ccqa hub");
13244
+ hint("edit guidance prompts in the hub UI's Prompts tab");
13245
+ hint("run `ccqa record <feature>/<spec>` to start recording");
13246
+ });
13247
+ //#endregion
13238
13248
  //#region src/cli/session.ts
13239
13249
  const AB = createRequire(import.meta.url).resolve("agent-browser/bin/agent-browser.js");
13240
13250
  /**
@@ -14107,7 +14117,7 @@ function createListProfilesHandler(storage) {
14107
14117
  //#region src/hub/api/handlers/prompts.ts
14108
14118
  const MAX_PROMPT_BODY_BYTES = 256 * 1024;
14109
14119
  /** Validate the `:project` route param (prompts are project-scoped, not per-profile). */
14110
- function requireProject(ctx) {
14120
+ function requireProject$1(ctx) {
14111
14121
  return requireSafeSegment(ctx.params.project, "project");
14112
14122
  }
14113
14123
  /**
@@ -14143,7 +14153,7 @@ function metaFor(name, body) {
14143
14153
  /** PUT /api/v1/projects/:project/prompts/:name — body is Markdown (guidance) or custom prompt JSON. */
14144
14154
  function createPutPromptHandler(config) {
14145
14155
  return async (ctx) => {
14146
- const project = requireProject(ctx);
14156
+ const project = requireProject$1(ctx);
14147
14157
  const name = requirePromptName(ctx);
14148
14158
  const body = await readBody(ctx.req, MAX_PROMPT_BODY_BYTES);
14149
14159
  await config.store.put(project, name, new Uint8Array(body), metaFor(name, body));
@@ -14154,7 +14164,7 @@ function createPutPromptHandler(config) {
14154
14164
  /** GET /api/v1/projects/:project/prompts — names + kinds + timestamps (no bodies). */
14155
14165
  function createListPromptsHandler(config) {
14156
14166
  return async (ctx) => {
14157
- const project = requireProject(ctx);
14167
+ const project = requireProject$1(ctx);
14158
14168
  const entries = await config.store.list(project);
14159
14169
  sendJson(ctx.res, 200, { prompts: entries.map((e) => ({
14160
14170
  name: e.name,
@@ -14167,7 +14177,7 @@ function createListPromptsHandler(config) {
14167
14177
  /** GET /api/v1/projects/:project/prompts/:name — the raw prompt body, or 404. */
14168
14178
  function createGetPromptHandler(config) {
14169
14179
  return async (ctx) => {
14170
- const project = requireProject(ctx);
14180
+ const project = requireProject$1(ctx);
14171
14181
  const name = requirePromptName(ctx);
14172
14182
  const stored = await config.store.get(project, name);
14173
14183
  if (!stored) throw new HttpError(404, "not_found", `prompt "${name}" not found for project "${project}"`);
@@ -14178,7 +14188,7 @@ function createGetPromptHandler(config) {
14178
14188
  /** DELETE /api/v1/projects/:project/prompts/:name */
14179
14189
  function createDeletePromptHandler(config) {
14180
14190
  return async (ctx) => {
14181
- const project = requireProject(ctx);
14191
+ const project = requireProject$1(ctx);
14182
14192
  const name = requirePromptName(ctx);
14183
14193
  await config.store.delete(project, name);
14184
14194
  ctx.res.statusCode = 204;
@@ -14186,6 +14196,93 @@ function createDeletePromptHandler(config) {
14186
14196
  };
14187
14197
  }
14188
14198
  //#endregion
14199
+ //#region src/hub/api/handlers/perspectives.ts
14200
+ const MAX_PERSPECTIVES_BODY_BYTES = 1024 * 1024;
14201
+ /** Validate the `:project` route param (perspectives are project-scoped, one document per project). */
14202
+ function requireProject(ctx) {
14203
+ return requireSafeSegment(ctx.params.project, "project");
14204
+ }
14205
+ /**
14206
+ * PUT /api/v1/projects/:project/perspectives — body is the perspectives
14207
+ * document as JSON. Schema validation is the CLI's job (it Zod-validates
14208
+ * before pushing); the hub only rejects bodies that aren't a JSON object,
14209
+ * so the UI can always JSON.parse what it reads back.
14210
+ */
14211
+ function createPutPerspectivesHandler(config) {
14212
+ return async (ctx) => {
14213
+ const project = requireProject(ctx);
14214
+ const body = await readBody(ctx.req, MAX_PERSPECTIVES_BODY_BYTES);
14215
+ let parsed;
14216
+ try {
14217
+ parsed = JSON.parse(body.toString("utf8"));
14218
+ } catch {
14219
+ throw new HttpError(400, "invalid_body", "perspectives body must be valid JSON");
14220
+ }
14221
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new HttpError(400, "invalid_body", "perspectives body must be a JSON object");
14222
+ await config.store.put(project, new Uint8Array(body));
14223
+ ctx.res.statusCode = 204;
14224
+ ctx.res.end();
14225
+ };
14226
+ }
14227
+ /** GET /api/v1/projects/:project/perspectives — the stored document, or 404. */
14228
+ function createGetPerspectivesHandler(config) {
14229
+ return async (ctx) => {
14230
+ const project = requireProject(ctx);
14231
+ const stored = await config.store.get(project);
14232
+ if (!stored) throw new HttpError(404, "not_found", `no perspectives stored for project "${project}"`);
14233
+ sendBytes(ctx.res, 200, stored, "application/json; charset=utf-8");
14234
+ };
14235
+ }
14236
+ /** DELETE /api/v1/projects/:project/perspectives */
14237
+ function createDeletePerspectivesHandler(config) {
14238
+ return async (ctx) => {
14239
+ const project = requireProject(ctx);
14240
+ await config.store.delete(project);
14241
+ ctx.res.statusCode = 204;
14242
+ ctx.res.end();
14243
+ };
14244
+ }
14245
+ const MAX_NOTE_BODY_BYTES = 64 * 1024;
14246
+ function parseNotePatch(body) {
14247
+ let parsed;
14248
+ try {
14249
+ parsed = JSON.parse(body.toString("utf8"));
14250
+ } catch {
14251
+ throw new HttpError(400, "invalid_body", "note patch must be valid JSON");
14252
+ }
14253
+ const rec = parsed;
14254
+ if (typeof rec?.feature !== "string" || rec.feature.length === 0 || typeof rec.spec !== "string" || rec.spec.length === 0 || typeof rec.note !== "string") throw new HttpError(400, "invalid_body", "note patch must be { feature, spec, note } with string values");
14255
+ return {
14256
+ feature: rec.feature,
14257
+ spec: rec.spec,
14258
+ note: rec.note
14259
+ };
14260
+ }
14261
+ /**
14262
+ * PATCH /api/v1/projects/:project/perspectives — the hub UI's note editing.
14263
+ * Body: `{ feature, spec, note }`; an empty `note` clears the field. The
14264
+ * note is the only human-authored field in the document, and this is its
14265
+ * only write path now that the document never lives in the consuming repo.
14266
+ * Runs as a serialized read-modify-write so an edit can't clobber (or be
14267
+ * clobbered by) a concurrent one.
14268
+ */
14269
+ function createPatchPerspectivesNoteHandler(config) {
14270
+ return async (ctx) => {
14271
+ const project = requireProject(ctx);
14272
+ const patch = parseNotePatch(await readBody(ctx.req, MAX_NOTE_BODY_BYTES));
14273
+ await config.store.update(project, (current) => {
14274
+ if (current === null) throw new HttpError(404, "not_found", `no perspectives stored for project "${project}"`);
14275
+ const specEntry = current.features?.find((f) => f?.featureName === patch.feature)?.specs?.find((s) => s?.specName === patch.spec);
14276
+ if (!specEntry) throw new HttpError(404, "not_found", `no spec "${patch.feature}/${patch.spec}" in the perspectives document`);
14277
+ if (patch.note === "") delete specEntry.note;
14278
+ else specEntry.note = patch.note;
14279
+ return current;
14280
+ });
14281
+ ctx.res.statusCode = 204;
14282
+ ctx.res.end();
14283
+ };
14284
+ }
14285
+ //#endregion
14189
14286
  //#region src/hub/api/handlers/learning-jobs.ts
14190
14287
  const MAX_BODY_BYTES = 4 * 1024;
14191
14288
  /** POST /api/v1/projects/:project/learning-jobs — create + enqueue a triage-learning job. */
@@ -14569,6 +14666,7 @@ const HTML_BODY = `
14569
14666
  <div class="nav-group" id="sidebar-project">no project</div>
14570
14667
  <nav class="nav">
14571
14668
  <a href="#/runs" class="nav-runs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg> <span data-i18n="nav.runs">Runs</span></a>
14669
+ <a href="#/perspectives" class="nav-perspectives"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M8 6h13M8 12h13M8 18h13"/><path d="M3 6h.01M3 12h.01M3 18h.01"/></svg> <span data-i18n="nav.perspectives">Perspectives</span></a>
14572
14670
  <a href="#/secrets" class="nav-secrets"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> <span data-i18n="nav.secrets">Secrets</span></a>
14573
14671
  <a href="#/prompts" class="nav-prompts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 4h11l5 5v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z"/><path d="M14 4v6h6"/><path d="M8 13h6M8 17h6"/></svg> <span data-i18n="nav.prompts">Prompts</span></a>
14574
14672
  <a href="#/jobs" class="nav-jobs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 2v4M12 18v4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M2 12h4M18 12h4M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/></svg> <span data-i18n="nav.learning">Learning</span></a>
@@ -14617,6 +14715,34 @@ const HTML_BODY = `
14617
14715
  </div>
14618
14716
  </section>
14619
14717
 
14718
+ <!-- ===== PERSPECTIVES ===== -->
14719
+ <section id="view-perspectives" hidden>
14720
+ <div class="page-bar">
14721
+ <h1 data-i18n="perspectives.title">Perspectives</h1>
14722
+ <span class="updated" id="persp-updated"></span>
14723
+ <div class="spacer"></div>
14724
+ <button class="btn ghost sm" id="persp-refresh" data-i18n="common.refresh">Refresh</button>
14725
+ </div>
14726
+ <div class="content">
14727
+ <p id="persp-status" class="empty-note" hidden></p>
14728
+ <div id="persp-body" hidden>
14729
+ <div class="ov" id="persp-ov"></div>
14730
+ <div class="toolbar">
14731
+ <label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg><input id="persp-q" type="search" data-i18n-ph="perspectives.search" aria-label="Search cases"></label>
14732
+ <button class="fchip" data-f="all" aria-pressed="true" type="button" data-i18n="perspectives.filter.all">All</button>
14733
+ <button class="fchip" data-f="deterministic" aria-pressed="false" type="button" data-i18n="perspectives.filter.deterministic">Deterministic</button>
14734
+ <button class="fchip" data-f="live" aria-pressed="false" type="button" data-i18n="perspectives.filter.live">Live</button>
14735
+ <button class="fchip" data-f="norec" aria-pressed="false" type="button" data-i18n="perspectives.filter.norec">Not recorded only</button>
14736
+ </div>
14737
+ <div class="tblcard"><div class="table-wrap"><table>
14738
+ <thead><tr><th data-i18n="perspectives.col.case">Case</th><th data-i18n="perspectives.col.mode">Mode</th><th data-i18n="perspectives.col.status">Status</th><th></th></tr></thead>
14739
+ <tbody id="persp-tbody"></tbody>
14740
+ </table></div></div>
14741
+ <p class="empty-note" id="persp-no-hit" hidden data-i18n="perspectives.noHit">No matching cases.</p>
14742
+ </div>
14743
+ </div>
14744
+ </section>
14745
+
14620
14746
  <!-- ===== RUN DETAIL ===== -->
14621
14747
  <section id="view-detail" hidden>
14622
14748
  <div class="page-bar">
@@ -15240,6 +15366,65 @@ const CSS = `
15240
15366
  .prompt-diff pre { margin: 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2);
15241
15367
  font-family: var(--mono); font-size: 11px; line-height: 1.5; color: var(--fg-dim); white-space: pre-wrap; word-break: break-word; max-height: 480px; overflow-y: auto; }
15242
15368
 
15369
+ /* perspectives — coverage strip, filter toolbar, and the one-table-per-project
15370
+ view (feature section rows + expandable case detail rows). Reuses the
15371
+ existing badge/chip primitives above; --pass/--amber cover the "runnable"
15372
+ vs "not recorded" coloring so no new tokens are needed. */
15373
+ .ov { display: flex; align-items: center; gap: 28px; padding: 14px 18px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-bottom: 16px; flex-wrap: wrap; }
15374
+ .ov .num { display: flex; flex-direction: column; }
15375
+ .ov .num b { font-size: 24px; line-height: 1.15; font-weight: 650; font-variant-numeric: tabular-nums; }
15376
+ .ov .num span { font-size: 12px; color: var(--muted); }
15377
+ .ov .sep { width: 1px; align-self: stretch; background: var(--border); }
15378
+ .ov .breakdown { display: flex; gap: 20px; }
15379
+ .covwrap { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 6px; }
15380
+ .covbar { height: 8px; border-radius: 999px; overflow: hidden; display: flex; background: var(--surface-3); }
15381
+ .covbar .ok { background: var(--pass); }
15382
+ .covbar .no { background: var(--amber); opacity: 0.75; }
15383
+ .covleg { display: flex; gap: 16px; font-size: 12px; color: var(--muted); }
15384
+ .covleg i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
15385
+ .covleg .lg-ok i { background: var(--pass); }
15386
+ .covleg .lg-no i { background: var(--amber); opacity: 0.75; }
15387
+
15388
+ .search { flex: 1; min-width: 200px; max-width: 340px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 0 10px; height: 32px; background: var(--surface); }
15389
+ .search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
15390
+ .search input { border: none; outline: none; font: inherit; font-size: 13px; width: 100%; background: transparent; color: var(--fg); }
15391
+ .fchip { border: 1px solid var(--border-strong); background: var(--surface); border-radius: 999px; padding: 5px 12px; font-size: 12.5px; color: var(--muted); }
15392
+ .fchip[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
15393
+
15394
+ .chip.live { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
15395
+ .badge.ok { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
15396
+ .badge.ok .d { background: var(--pass); }
15397
+ .badge.norec { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
15398
+ .badge.norec .d { background: var(--amber); }
15399
+
15400
+ .tblcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
15401
+ /* Feature section rows must read as headings, not as just another data row —
15402
+ larger, darker, extra padding, and a strong top rule marking the break. */
15403
+ tr.grp td { background: var(--surface-2); border-top: 2px solid var(--border-strong); border-bottom: 1px solid var(--border); padding: 12px 12px 10px; font-family: var(--mono); font-size: 15px; font-weight: 700; color: var(--fg); }
15404
+ tr.grp td .gcount { color: var(--muted); font-weight: 500; font-size: 12px; font-family: var(--font); margin-left: 10px; }
15405
+ td.c-title { font-weight: 500; max-width: 460px; }
15406
+ td.c-title .csum { display: block; font-weight: 400; color: var(--muted); font-size: 12.5px; margin-top: 1px; }
15407
+ td.c-chev { width: 28px; color: var(--muted-2); text-align: right; }
15408
+ .chev-i { display: inline-block; transition: transform 0.15s; font-size: 11px; }
15409
+ tr.row[aria-expanded="true"] .chev-i { transform: rotate(90deg); }
15410
+ tr.detail { display: none; }
15411
+ tr.detail.open { display: table-row; }
15412
+ tr.detail > td { background: var(--surface-2); padding: 14px 16px 16px; }
15413
+
15414
+ .d-grid { display: grid; grid-template-columns: 120px 1fr; gap: 7px 14px; font-size: 13px; max-width: 860px; }
15415
+ .d-grid dt { color: var(--muted); font-size: 12px; padding-top: 1px; }
15416
+ .d-grid dd { color: var(--fg-dim); }
15417
+ .d-grid dd ul { list-style: none; display: flex; flex-direction: column; gap: 3px; margin: 0; padding: 0; }
15418
+ .d-grid dd li::before { content: "\\2022 "; color: var(--muted-2); }
15419
+ .d-grid code { font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
15420
+ .notebox { margin-top: 12px; max-width: 860px; }
15421
+ .notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
15422
+ .notebox textarea { width: 100%; min-height: 54px; resize: vertical; font: inherit; font-size: 13px; color: var(--fg-dim); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 10px; }
15423
+ .notebox .nact { margin-top: 6px; display: flex; align-items: center; gap: 8px; }
15424
+ .notebox .nstatus { font-size: 12px; color: var(--muted); }
15425
+ .notebox .nstatus.ok { color: var(--pass); }
15426
+ .notebox .nstatus.err { color: var(--fail); }
15427
+
15243
15428
  @media (max-width: 900px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } .logo .wm { display: none; } .split { grid-template-columns: 1fr; } .rd-head .meta { margin-left: 0; } }
15244
15429
  @media (max-width: 700px) { .prompt-grid { grid-template-columns: 1fr; } .prompt-diff { grid-template-columns: 1fr; } }
15245
15430
  `;
@@ -15266,7 +15451,7 @@ const CLIENT_JS = `
15266
15451
  // only their display text is localized via FAILURE_LABEL_JA.
15267
15452
  var I18N = {
15268
15453
  en: {
15269
- "nav.projects": "Projects", "nav.runs": "Runs", "nav.secrets": "Secrets",
15454
+ "nav.projects": "Projects", "nav.runs": "Runs", "nav.perspectives": "Perspectives", "nav.secrets": "Secrets",
15270
15455
  "nav.prompts": "Prompts", "nav.learning": "Learning",
15271
15456
  "app.project": "project", "app.profile": "profile", "app.disconnect": "Disconnect", "app.noProject": "no project",
15272
15457
  "app.newProfile": "New profile",
@@ -15308,6 +15493,27 @@ const CLIENT_JS = `
15308
15493
  "learn.cta.desc": "Learn from what you graded so ccqa classifies failure causes the same way next time.",
15309
15494
  "learn.cta.run": "Learn",
15310
15495
  "secrets.title": "Secrets", "prompts.title": "Prompts", "learning.title": "Learning",
15496
+ "perspectives.title": "Perspectives",
15497
+ "perspectives.search": "Search cases…",
15498
+ "perspectives.filter.all": "All", "perspectives.filter.deterministic": "Deterministic",
15499
+ "perspectives.filter.live": "Live", "perspectives.filter.norec": "Not recorded only",
15500
+ "perspectives.col.case": "Case", "perspectives.col.mode": "Mode", "perspectives.col.status": "Status",
15501
+ "perspectives.noHit": "No matching cases.",
15502
+ "perspectives.updated": "Last updated:",
15503
+ "perspectives.empty": "No perspectives yet. Run ccqa perspectives, or record a test — it's created automatically.",
15504
+ "perspectives.loadFailed": "Loading perspectives failed",
15505
+ "perspectives.mode.deterministic": "deterministic", "perspectives.mode.live": "live",
15506
+ "perspectives.status.runnable": "runnable", "perspectives.status.notRecorded": "not recorded",
15507
+ "perspectives.metric.features": "Features", "perspectives.metric.cases": "Test cases",
15508
+ "perspectives.metric.deterministic": "Deterministic", "perspectives.metric.live": "Live",
15509
+ "perspectives.cov.runnable": "runnable", "perspectives.cov.notRecorded": "not recorded",
15510
+ "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
15511
+ "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
15512
+ "perspectives.d.relatedPaths": "Related code",
15513
+ "perspectives.note.label": "Note",
15514
+ "perspectives.note.placeholder": "Notes about this case…",
15515
+ "perspectives.note.saved": "Saved",
15516
+ "perspectives.note.error": "Could not save — retry",
15311
15517
  "prompt.card.record": "Recording browser actions",
15312
15518
  "prompt.card.live": "Live run (AI-driven)",
15313
15519
  "prompt.card.customPrompt": "Failure-cause classification",
@@ -15335,7 +15541,7 @@ const CLIENT_JS = `
15335
15541
  "jobs.failed": "The learning job failed.", "jobs.newCustomPrompt": "New custom prompt:", "jobs.empty": "No learning jobs yet. Grade failing specs on a run, then Learn."
15336
15542
  },
15337
15543
  ja: {
15338
- "nav.projects": "プロジェクト", "nav.runs": "実行", "nav.secrets": "シークレット",
15544
+ "nav.projects": "プロジェクト", "nav.runs": "実行", "nav.perspectives": "Perspectives", "nav.secrets": "シークレット",
15339
15545
  "nav.prompts": "プロンプト", "nav.learning": "学習",
15340
15546
  "app.project": "プロジェクト", "app.profile": "プロファイル", "app.disconnect": "切断", "app.noProject": "プロジェクト未選択",
15341
15547
  "app.newProfile": "新規プロファイル",
@@ -15377,6 +15583,27 @@ const CLIENT_JS = `
15377
15583
  "learn.cta.desc": "採点した内容をもとに、ccqaが次回から同じように失敗の原因を分類できるよう学習します。",
15378
15584
  "learn.cta.run": "学習",
15379
15585
  "secrets.title": "シークレット", "prompts.title": "プロンプト", "learning.title": "学習",
15586
+ "perspectives.title": "Perspectives",
15587
+ "perspectives.search": "ケースを検索…",
15588
+ "perspectives.filter.all": "すべて", "perspectives.filter.deterministic": "決定的",
15589
+ "perspectives.filter.live": "ライブ", "perspectives.filter.norec": "未recordのみ",
15590
+ "perspectives.col.case": "ケース", "perspectives.col.mode": "モード", "perspectives.col.status": "状態",
15591
+ "perspectives.noHit": "該当するケースがありません。",
15592
+ "perspectives.updated": "最終更新:",
15593
+ "perspectives.empty": "まだperspectivesがありません。ccqa perspectives を実行するか、recordすると自動作成されます。",
15594
+ "perspectives.loadFailed": "perspectivesの読み込みに失敗しました",
15595
+ "perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
15596
+ "perspectives.status.runnable": "実行可能", "perspectives.status.notRecorded": "未record",
15597
+ "perspectives.metric.features": "機能", "perspectives.metric.cases": "テストケース",
15598
+ "perspectives.metric.deterministic": "決定的", "perspectives.metric.live": "ライブ",
15599
+ "perspectives.cov.runnable": "実行可能", "perspectives.cov.notRecorded": "未record",
15600
+ "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
15601
+ "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
15602
+ "perspectives.d.relatedPaths": "関連コード",
15603
+ "perspectives.note.label": "note",
15604
+ "perspectives.note.placeholder": "このケースについてのメモ…",
15605
+ "perspectives.note.saved": "保存しました",
15606
+ "perspectives.note.error": "保存に失敗しました — 再試行してください",
15380
15607
  "prompt.card.record": "ブラウザ操作の記録",
15381
15608
  "prompt.card.live": "ライブ実行(AI操作)",
15382
15609
  "prompt.card.customPrompt": "失敗原因の分類",
@@ -15632,8 +15859,8 @@ const CLIENT_JS = `
15632
15859
 
15633
15860
  // ── view routing ────────────────────────────────────────────────────
15634
15861
 
15635
- var VIEWS = ["projects", "runs", "detail", "secrets", "prompts", "jobs"];
15636
- var NAV_FOR_VIEW = { projects: ".nav-projects", secrets: ".nav-secrets", prompts: ".nav-prompts", runs: ".nav-runs", detail: ".nav-runs", jobs: ".nav-jobs" };
15862
+ var VIEWS = ["projects", "runs", "detail", "perspectives", "secrets", "prompts", "jobs"];
15863
+ var NAV_FOR_VIEW = { projects: ".nav-projects", secrets: ".nav-secrets", prompts: ".nav-prompts", runs: ".nav-runs", detail: ".nav-runs", jobs: ".nav-jobs", perspectives: ".nav-perspectives" };
15637
15864
  function showView(id) {
15638
15865
  // Any in-flight job poll belongs to the view we're leaving — bump the token
15639
15866
  // so its next tick is a no-op (see pollJob).
@@ -15651,6 +15878,7 @@ const CLIENT_JS = `
15651
15878
  function updateNavGate() {
15652
15879
  var gated = !state.project;
15653
15880
  document.querySelector(".nav-runs").classList.toggle("disabled", gated);
15881
+ document.querySelector(".nav-perspectives").classList.toggle("disabled", gated);
15654
15882
  document.querySelector(".nav-secrets").classList.toggle("disabled", gated);
15655
15883
  document.querySelector(".nav-prompts").classList.toggle("disabled", gated);
15656
15884
  document.querySelector(".nav-jobs").classList.toggle("disabled", gated);
@@ -15666,6 +15894,7 @@ const CLIENT_JS = `
15666
15894
  if (location.hash === "#/projects" || !state.project) { openProjects(); return; }
15667
15895
  var m = location.hash.match(/^#\\/runs\\/(.+)$/);
15668
15896
  if (m) { openRunDetail(decodeURIComponent(m[1])); return; }
15897
+ if (location.hash === "#/perspectives") { openPerspectives(); return; }
15669
15898
  if (location.hash === "#/secrets") { openSecrets(); return; }
15670
15899
  if (location.hash === "#/prompts") { openPrompts(); return; }
15671
15900
  var j = location.hash.match(/^#\\/jobs\\/(.+)$/);
@@ -16922,6 +17151,305 @@ const CLIENT_JS = `
16922
17151
  loadPrompts();
16923
17152
  }
16924
17153
 
17154
+ // ── perspectives ──────────────────────────────────────────────────────
17155
+ // A read-mostly coverage inventory the CLI generates ("ccqa perspectives" /
17156
+ // record); the hub UI's only write path is the per-case note (PATCH). The
17157
+ // whole document is fetched once per view-open and filtered/rendered
17158
+ // client-side — small enough that there is no pagination.
17159
+
17160
+ var perspState = { doc: null, q: "", f: "all" };
17161
+
17162
+ function perspectivesPath() {
17163
+ return "/api/v1/projects/" + encodeURIComponent(state.project) + "/perspectives";
17164
+ }
17165
+
17166
+ // GET the perspectives document. Resolves null on 404 (no document stored
17167
+ // yet — the normal "nothing recorded" state) instead of throwing, so
17168
+ // loadPerspectives can tell that apart from a real fetch/parse failure.
17169
+ function fetchPerspectives() {
17170
+ return fetch(perspectivesPath(), { headers: { Authorization: "Bearer " + state.token } }).then(function (res) {
17171
+ if (res.status === 404) return null;
17172
+ if (!res.ok) throw new Error(res.status + " " + res.statusText);
17173
+ return res.json();
17174
+ }, function () { throw new Error("Network unreachable — check the hub URL and your connection"); });
17175
+ }
17176
+
17177
+ // The execution mode lives inside the mechanically-derived status object
17178
+ // (spec.status.mode), not at the top level of a spec entry.
17179
+ function perspMode(spec) {
17180
+ return spec.status && spec.status.mode === "live" ? "live" : "deterministic";
17181
+ }
17182
+
17183
+ function perspRunnable(spec) {
17184
+ return perspMode(spec) === "live" || (spec.status && spec.status.generated === true);
17185
+ }
17186
+
17187
+ function setPerspStatus(message) {
17188
+ var box = document.getElementById("persp-status");
17189
+ box.hidden = !message;
17190
+ box.textContent = message || "";
17191
+ }
17192
+
17193
+ function perspModeChip(mode) {
17194
+ var span = el("span", "chip" + (mode === "live" ? " live" : ""));
17195
+ span.textContent = t(mode === "live" ? "perspectives.mode.live" : "perspectives.mode.deterministic");
17196
+ return span;
17197
+ }
17198
+
17199
+ function perspStatusBadge(spec) {
17200
+ var ok = perspRunnable(spec);
17201
+ var span = el("span", "badge " + (ok ? "ok" : "norec"));
17202
+ span.appendChild(el("span", "d"));
17203
+ span.appendChild(document.createTextNode(" " + t(ok ? "perspectives.status.runnable" : "perspectives.status.notRecorded")));
17204
+ return span;
17205
+ }
17206
+
17207
+ // Overview strip: feature/case counts, deterministic/live breakdown, and a
17208
+ // runnable-vs-not-recorded coverage bar.
17209
+ function renderPerspOverview(doc) {
17210
+ var host = document.getElementById("persp-ov");
17211
+ clear(host);
17212
+ var allSpecs = doc.features.reduce(function (acc, f) { return acc.concat(f.specs); }, []);
17213
+ var total = allSpecs.length;
17214
+ var ok = allSpecs.filter(perspRunnable).length;
17215
+ var no = total - ok;
17216
+ var det = allSpecs.filter(function (s) { return perspMode(s) === "deterministic"; }).length;
17217
+ var live = total - det;
17218
+
17219
+ function numBlock(value, labelKey) {
17220
+ var box = el("div", "num");
17221
+ box.appendChild(el("b", null, String(value)));
17222
+ box.appendChild(el("span", null, t(labelKey)));
17223
+ return box;
17224
+ }
17225
+
17226
+ host.appendChild(numBlock(doc.features.length, "perspectives.metric.features"));
17227
+ host.appendChild(el("div", "sep"));
17228
+ host.appendChild(numBlock(total, "perspectives.metric.cases"));
17229
+ host.appendChild(el("div", "sep"));
17230
+ var breakdown = el("div", "breakdown");
17231
+ breakdown.appendChild(numBlock(det, "perspectives.metric.deterministic"));
17232
+ breakdown.appendChild(numBlock(live, "perspectives.metric.live"));
17233
+ host.appendChild(breakdown);
17234
+
17235
+ var covwrap = el("div", "covwrap");
17236
+ var covbar = el("div", "covbar");
17237
+ if (total > 0) {
17238
+ var okBar = el("div", "ok");
17239
+ okBar.style.width = (ok / total) * 100 + "%";
17240
+ var noBar = el("div", "no");
17241
+ noBar.style.width = (no / total) * 100 + "%";
17242
+ covbar.appendChild(okBar);
17243
+ covbar.appendChild(noBar);
17244
+ }
17245
+ covwrap.appendChild(covbar);
17246
+ var covleg = el("div", "covleg");
17247
+ var legOk = el("span", "lg-ok");
17248
+ legOk.appendChild(el("i"));
17249
+ legOk.appendChild(document.createTextNode(t("perspectives.cov.runnable") + " " + ok));
17250
+ covleg.appendChild(legOk);
17251
+ if (no > 0) {
17252
+ var legNo = el("span", "lg-no");
17253
+ legNo.appendChild(el("i"));
17254
+ legNo.appendChild(document.createTextNode(t("perspectives.cov.notRecorded") + " " + no));
17255
+ covleg.appendChild(legNo);
17256
+ }
17257
+ covwrap.appendChild(covleg);
17258
+ host.appendChild(covwrap);
17259
+ }
17260
+
17261
+ function perspMatches(spec) {
17262
+ if (perspState.f === "deterministic" && perspMode(spec) !== "deterministic") return false;
17263
+ if (perspState.f === "live" && perspMode(spec) !== "live") return false;
17264
+ if (perspState.f === "norec" && perspRunnable(spec)) return false;
17265
+ if (perspState.q) {
17266
+ var hay = (spec.title + " " + (spec.summary || "") + " " + spec.specName).toLowerCase();
17267
+ if (hay.indexOf(perspState.q) === -1) return false;
17268
+ }
17269
+ return true;
17270
+ }
17271
+
17272
+ // Detail row: a definition list of the case's fields plus the note editor.
17273
+ // Built with createElement/textContent throughout — every field here is
17274
+ // API-derived, so none of it may go through innerHTML.
17275
+ function perspDetailContent(feature, spec) {
17276
+ var frag = document.createDocumentFragment();
17277
+ var dl = el("dl", "d-grid");
17278
+ function row(labelKey, valueNode) {
17279
+ dl.appendChild(el("dt", null, t(labelKey)));
17280
+ var dd = el("dd");
17281
+ if (typeof valueNode === "string") dd.textContent = valueNode;
17282
+ else dd.appendChild(valueNode);
17283
+ dl.appendChild(dd);
17284
+ }
17285
+ if (spec.preconditions && spec.preconditions.length) {
17286
+ var ul = el("ul");
17287
+ spec.preconditions.forEach(function (p) { ul.appendChild(el("li", null, p)); });
17288
+ row("perspectives.d.preconditions", ul);
17289
+ }
17290
+ if (spec.startScreen) row("perspectives.d.startScreen", spec.startScreen);
17291
+ if (spec.testCondition) row("perspectives.d.testCondition", spec.testCondition);
17292
+ var specCode = el("code", null, spec.specName);
17293
+ row("perspectives.d.spec", specCode);
17294
+ if (spec.relatedPaths && spec.relatedPaths.length) {
17295
+ var pathsWrap = el("span");
17296
+ spec.relatedPaths.forEach(function (p, i) {
17297
+ if (i > 0) pathsWrap.appendChild(document.createTextNode(" "));
17298
+ pathsWrap.appendChild(el("code", null, p));
17299
+ });
17300
+ row("perspectives.d.relatedPaths", pathsWrap);
17301
+ }
17302
+ frag.appendChild(dl);
17303
+
17304
+ var notebox = el("div", "notebox");
17305
+ notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
17306
+ var ta = el("textarea");
17307
+ ta.placeholder = t("perspectives.note.placeholder");
17308
+ ta.value = spec.note || "";
17309
+ notebox.appendChild(ta);
17310
+ var nact = el("div", "nact");
17311
+ var saveBtn = el("button", "btn primary", t("common.save"));
17312
+ saveBtn.type = "button";
17313
+ var statusEl = el("span", "nstatus");
17314
+ nact.appendChild(saveBtn);
17315
+ nact.appendChild(statusEl);
17316
+ notebox.appendChild(nact);
17317
+ frag.appendChild(notebox);
17318
+
17319
+ saveBtn.addEventListener("click", function () {
17320
+ saveBtn.disabled = true;
17321
+ statusEl.className = "nstatus";
17322
+ statusEl.textContent = "";
17323
+ apiFetch(perspectivesPath(), {
17324
+ method: "PATCH",
17325
+ headers: { "Content-Type": "application/json" },
17326
+ body: JSON.stringify({ feature: feature.featureName, spec: spec.specName, note: ta.value }),
17327
+ }).then(function () {
17328
+ spec.note = ta.value || undefined;
17329
+ statusEl.className = "nstatus ok";
17330
+ statusEl.textContent = t("perspectives.note.saved");
17331
+ }).catch(function (err) {
17332
+ statusEl.className = "nstatus err";
17333
+ statusEl.textContent = t("perspectives.note.error") + ": " + err.message;
17334
+ }).then(function () { saveBtn.disabled = false; });
17335
+ });
17336
+
17337
+ return frag;
17338
+ }
17339
+
17340
+ function renderPerspTable(doc) {
17341
+ var tbody = document.getElementById("persp-tbody");
17342
+ clear(tbody);
17343
+ var hits = 0;
17344
+ doc.features.forEach(function (feature) {
17345
+ var specs = feature.specs.filter(perspMatches);
17346
+ if (!specs.length) return;
17347
+ hits += specs.length;
17348
+
17349
+ var grpRow = el("tr", "grp");
17350
+ var grpTd = el("td");
17351
+ grpTd.colSpan = 4;
17352
+ grpTd.appendChild(document.createTextNode(feature.featureName));
17353
+ grpTd.appendChild(el("span", "gcount", specs.length + " " + t("perspectives.metric.cases").toLowerCase()));
17354
+ grpRow.appendChild(grpTd);
17355
+ tbody.appendChild(grpRow);
17356
+
17357
+ specs.forEach(function (spec) {
17358
+ var row = el("tr", "row");
17359
+ row.tabIndex = 0;
17360
+ row.setAttribute("aria-expanded", "false");
17361
+
17362
+ var titleTd = el("td", "c-title");
17363
+ titleTd.appendChild(document.createTextNode(spec.title));
17364
+ if (spec.summary) titleTd.appendChild(el("span", "csum", spec.summary));
17365
+ row.appendChild(titleTd);
17366
+
17367
+ var modeTd = el("td");
17368
+ modeTd.appendChild(perspModeChip(perspMode(spec)));
17369
+ row.appendChild(modeTd);
17370
+
17371
+ var statusTd = el("td");
17372
+ statusTd.appendChild(perspStatusBadge(spec));
17373
+ row.appendChild(statusTd);
17374
+
17375
+ var chevTd = el("td", "c-chev");
17376
+ chevTd.appendChild(el("span", "chev-i", "\\u25b6"));
17377
+ row.appendChild(chevTd);
17378
+
17379
+ var detailRow = el("tr", "detail");
17380
+ var detailTd = el("td");
17381
+ detailTd.colSpan = 4;
17382
+ detailRow.appendChild(detailTd);
17383
+ var built = false;
17384
+
17385
+ function toggle() {
17386
+ var open = detailRow.classList.toggle("open");
17387
+ row.setAttribute("aria-expanded", open ? "true" : "false");
17388
+ if (open && !built) {
17389
+ detailTd.appendChild(perspDetailContent(feature, spec));
17390
+ built = true;
17391
+ }
17392
+ }
17393
+ row.addEventListener("click", function (e) {
17394
+ if (e.target.tagName === "TEXTAREA" || e.target.tagName === "BUTTON") return;
17395
+ toggle();
17396
+ });
17397
+ row.addEventListener("keydown", function (e) {
17398
+ if ((e.key === "Enter" || e.key === " ") && e.target === row) { e.preventDefault(); toggle(); }
17399
+ });
17400
+
17401
+ tbody.appendChild(row);
17402
+ tbody.appendChild(detailRow);
17403
+ });
17404
+ });
17405
+ document.getElementById("persp-no-hit").hidden = hits > 0;
17406
+ }
17407
+
17408
+ function renderPerspectives() {
17409
+ var doc = perspState.doc;
17410
+ if (!doc) return;
17411
+ renderPerspOverview(doc);
17412
+ renderPerspTable(doc);
17413
+ }
17414
+
17415
+ function setPerspUpdated(doc) {
17416
+ var span = document.getElementById("persp-updated");
17417
+ if (doc && doc.generatedAt) {
17418
+ span.hidden = false;
17419
+ span.textContent = t("perspectives.updated") + " " + relTime(doc.generatedAt);
17420
+ } else {
17421
+ span.hidden = true;
17422
+ span.textContent = "";
17423
+ }
17424
+ }
17425
+
17426
+ function loadPerspectives() {
17427
+ setPerspStatus("");
17428
+ document.getElementById("persp-body").hidden = true;
17429
+ setPerspUpdated(null);
17430
+ fetchPerspectives()
17431
+ .then(function (doc) {
17432
+ perspState.doc = doc;
17433
+ if (!doc) { setPerspStatus(t("perspectives.empty")); return; }
17434
+ setPerspUpdated(doc);
17435
+ document.getElementById("persp-body").hidden = false;
17436
+ renderPerspectives();
17437
+ })
17438
+ .catch(function (err) {
17439
+ perspState.doc = null;
17440
+ setPerspStatus(t("perspectives.loadFailed") + ": " + err.message);
17441
+ });
17442
+ }
17443
+
17444
+ function openPerspectives() {
17445
+ showView("perspectives");
17446
+ document.getElementById("persp-q").value = perspState.q;
17447
+ document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
17448
+ b.setAttribute("aria-pressed", String(b.getAttribute("data-f") === perspState.f));
17449
+ });
17450
+ loadPerspectives();
17451
+ }
17452
+
16925
17453
  // The custom prompt body is JSON (schemaVersion/basePromptVersion/customPromptVersion/
16926
17454
  // generatedAt/guidance); the textarea only ever shows the learned guidance
16927
17455
  // text, never the raw JSON. A parse failure falls back to the raw text so a
@@ -17313,6 +17841,22 @@ const CLIENT_JS = `
17313
17841
  // argument (which would render "[object PointerEvent]" in the status box).
17314
17842
  document.getElementById("sec-load").addEventListener("click", function () { loadSecrets(); });
17315
17843
 
17844
+ // perspectives view
17845
+ document.getElementById("persp-refresh").addEventListener("click", function () { loadPerspectives(); });
17846
+ document.getElementById("persp-q").addEventListener("input", function (e) {
17847
+ perspState.q = e.target.value.trim().toLowerCase();
17848
+ renderPerspectives();
17849
+ });
17850
+ document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
17851
+ b.addEventListener("click", function () {
17852
+ perspState.f = b.getAttribute("data-f");
17853
+ document.querySelectorAll("#view-perspectives .fchip").forEach(function (x) {
17854
+ x.setAttribute("aria-pressed", x === b ? "true" : "false");
17855
+ });
17856
+ renderPerspectives();
17857
+ });
17858
+ });
17859
+
17316
17860
  // prompts view
17317
17861
  document.getElementById("pr-load").addEventListener("click", function () { loadPrompts(); });
17318
17862
 
@@ -17717,6 +18261,11 @@ function registerRoutes(router, config, queue) {
17717
18261
  router.get("/api/v1/projects/:project/prompts", createListPromptsHandler(promptConfig));
17718
18262
  router.get("/api/v1/projects/:project/prompts/:name", createGetPromptHandler(promptConfig));
17719
18263
  router.delete("/api/v1/projects/:project/prompts/:name", createDeletePromptHandler(promptConfig));
18264
+ const perspectivesConfig = { store: storage.perspectives };
18265
+ router.put("/api/v1/projects/:project/perspectives", createPutPerspectivesHandler(perspectivesConfig));
18266
+ router.get("/api/v1/projects/:project/perspectives", createGetPerspectivesHandler(perspectivesConfig));
18267
+ router.patch("/api/v1/projects/:project/perspectives", createPatchPerspectivesNoteHandler(perspectivesConfig));
18268
+ router.delete("/api/v1/projects/:project/perspectives", createDeletePerspectivesHandler(perspectivesConfig));
17720
18269
  router.post("/api/v1/projects/:project/learning-jobs", createCreateLearningJobHandler({
17721
18270
  storage,
17722
18271
  queue
@@ -17918,6 +18467,12 @@ function promptBlobPath(root, project, name) {
17918
18467
  function promptMetaPath(root, project, name) {
17919
18468
  return join(promptProjectDir(root, project), `${name}.meta.json`);
17920
18469
  }
18470
+ function perspectivesKindDir(root) {
18471
+ return join(root, "perspectives");
18472
+ }
18473
+ function perspectivesPath(root, project) {
18474
+ return join(perspectivesKindDir(root), `${project}.json`);
18475
+ }
17921
18476
  //#endregion
17922
18477
  //#region src/hub/core/storage/file/artifact-store.ts
17923
18478
  /**
@@ -18005,6 +18560,41 @@ function createFileJobStore(root) {
18005
18560
  };
18006
18561
  }
18007
18562
  //#endregion
18563
+ //#region src/hub/core/storage/file/perspectives-store.ts
18564
+ /**
18565
+ * Defense-in-depth path validation: the HTTP layer already checks the project
18566
+ * segment, but this builds a file path from it, so it re-checks rather than
18567
+ * trusting callers. Mirrors the sibling prompt/secret stores.
18568
+ */
18569
+ function assertSafeName$2(value, label) {
18570
+ if (value.length === 0 || value.includes("/") || value.includes("\\") || value.split(/[\\/]/).includes("..") || value === ".") throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
18571
+ }
18572
+ /**
18573
+ * Perspectives storage: one JSON document per project, plain UTF-8 with no
18574
+ * encryption (an inventory of what is tested is not a secret). No meta file —
18575
+ * the document's own `generatedAt` is its timestamp.
18576
+ */
18577
+ function createFilePerspectivesStore(root) {
18578
+ return {
18579
+ async put(project, blob) {
18580
+ assertSafeName$2(project, "project");
18581
+ await writeBytes(perspectivesPath(root, project), blob);
18582
+ },
18583
+ async get(project) {
18584
+ assertSafeName$2(project, "project");
18585
+ return readBytesOrNull(perspectivesPath(root, project));
18586
+ },
18587
+ async update(project, mutate) {
18588
+ assertSafeName$2(project, "project");
18589
+ await updateJson(perspectivesPath(root, project), mutate);
18590
+ },
18591
+ async delete(project) {
18592
+ assertSafeName$2(project, "project");
18593
+ await removePath(perspectivesPath(root, project));
18594
+ }
18595
+ };
18596
+ }
18597
+ //#endregion
18008
18598
  //#region src/hub/core/storage/file/prompt-store.ts
18009
18599
  /**
18010
18600
  * Defense-in-depth path validation: the HTTP layer already checks project/name,
@@ -18222,6 +18812,7 @@ function createFileHubStorage(dataDir) {
18222
18812
  variables: createFileSecretStore(dataDir, "variables"),
18223
18813
  triage: createFileTriageStore(dataDir),
18224
18814
  prompts: createFilePromptStore(dataDir),
18815
+ perspectives: createFilePerspectivesStore(dataDir),
18225
18816
  jobs: createFileJobStore(dataDir)
18226
18817
  };
18227
18818
  }