ccqa 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -11489,543 +11454,247 @@ async function resolveCwdPrefix(cwd) {
11489
11454
  }
11490
11455
  }
11491
11456
  //#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
- }
11457
+ //#region src/prompts/perspectives.ts
11510
11458
  /**
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.
11459
+ * Build the system prompt. By default the descriptive fields follow the
11460
+ * spec's own language (Japanese specs Japanese fields). An explicit
11461
+ * `--language` is applied by the CLI via `languageDirective`, appended to
11462
+ * this prompt, so the language handling lives in one shared place.
11516
11463
  */
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
- }
11464
+ function buildPerspectivesSystemPrompt() {
11465
+ return `You produce a factual inventory of the E2E test coverage that already exists in a ccqa project.
11466
+
11467
+ 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.
11468
+
11469
+ ## Hard boundaries (do NOT cross)
11470
+
11471
+ - 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.
11472
+ - Do NOT do gap analysis. Do NOT list untested areas, missing coverage, or things the code has but the tests lack.
11473
+ - Do NOT evaluate whether the feature is good, complete, or correct.
11474
+ - Do NOT propose new test cases.
11475
+ - 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.
11476
+ - Do NOT touch status, relatedPaths, feature names, or spec names — the CLI already fixed those.
11477
+
11478
+ ## Fields to write (per spec)
11479
+
11480
+ - \`summary\`: 1–2 sentences, factual and neutral. What the test exercises and what it ultimately asserts, derived from the spec's \`steps\` (\`instruction\` / \`expected\`).
11481
+ - \`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.
11482
+ - \`testCondition\`: the state/precondition the scenario assumes, phrased as a condition (e.g. "Logged in as an admin", "Unauthenticated user"). Omit if none.
11483
+ - \`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.
11484
+
11485
+ ## How to write
11486
+
11487
+ - Same language as the spec's title (if titles are Japanese, write these fields in Japanese).
11488
+ - Keep each field short. These are index entries, not the test itself.
11489
+ - You may use Read/Grep/Glob sparingly to clarify domain vocabulary, but the steps are the primary source. Do not over-explore.
11490
+
11491
+ ## Output contract (STRICT)
11492
+
11493
+ Output exactly ONE fenced \`\`\`json code block, and nothing else outside it. No prose before or after.
11494
+
11495
+ Schema:
11496
+
11497
+ \`\`\`json
11498
+ {
11499
+ "summaries": [
11500
+ {
11501
+ "featureName": "<verbatim from input>",
11502
+ "specName": "<verbatim from input>",
11503
+ "summary": "<1–2 sentence factual description of what this test verifies>",
11504
+ "startScreen": "<opening screen/URL, or omit>",
11505
+ "testCondition": "<assumed state phrased as a condition, or omit>",
11506
+ "preconditions": ["<setup prerequisite>", "..."]
11507
+ }
11508
+ ]
11527
11509
  }
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;
11550
- }
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
11570
- }
11571
- };
11572
- if (!(await target.generate(ctx)).passed) {
11573
- warn("auto-fix exhausted; test still failing");
11574
- process.exit(1);
11575
- }
11576
- hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
11510
+ \`\`\`
11511
+
11512
+ 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.
11513
+ `;
11577
11514
  }
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;
11515
+ function buildPerspectivesPrompt(specs, instruction) {
11516
+ return `## Existing test cases to summarise
11517
+
11518
+ ${specs.map((s) => `### ${s.featureName}/${s.specName}
11519
+ title: ${s.title}
11520
+
11521
+ \`\`\`yaml
11522
+ ${s.specYaml.trimEnd()}
11523
+ \`\`\`
11524
+ `).join("\n")}
11525
+ ${instruction?.trim() ? `## Extra guidance from the user\n\n${instruction.trim()}\n\n` : ""}## Task
11526
+
11527
+ 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.
11528
+ `;
11529
+ }
11530
+ //#endregion
11531
+ //#region src/cli/draft.ts
11532
+ const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
11533
+ 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) => {
11534
+ await ensureCcqaDir();
11535
+ let featureName;
11536
+ let specName;
11537
+ let prefilledIntent = null;
11538
+ if (specPath) ({featureName, specName} = parseSpecPath(specPath));
11539
+ else {
11540
+ const { naming, intent } = await proposeNaming(opts);
11541
+ featureName = naming.featureName;
11542
+ specName = naming.specName;
11543
+ prefilledIntent = intent;
11582
11544
  }
11583
- const rl = createInterface({
11584
- input: process.stdin,
11585
- output: process.stdout
11586
- });
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();
11545
+ await runDraft(featureName, specName, opts, prefilledIntent);
11546
+ });
11547
+ async function runDraft(featureName, specName, opts, prefilledIntent) {
11548
+ header("draft", `${featureName}/${specName}`);
11549
+ const ja = useJapanesePrompts(opts.language);
11550
+ const oneShot = opts.instruction !== void 0;
11551
+ let useIntentOnce = prefilledIntent !== null && !oneShot;
11552
+ while (true) {
11553
+ const existing = await tryReadSpecFile(featureName, specName);
11554
+ const isFirstRun = existing === null;
11555
+ let userInput;
11556
+ if (oneShot) userInput = opts.instruction ?? "";
11557
+ else if (useIntentOnce && isFirstRun) {
11558
+ userInput = prefilledIntent ?? "";
11559
+ useIntentOnce = false;
11560
+ } else userInput = await prompt(isFirstRun ? ja ? "何をテストしたいですか? > " : "What do you want to test? > " : ja ? "どのように修正しますか? (空欄で再検証) > " : "How would you like to refine? (empty = re-validate) > ");
11561
+ if (isFirstRun && !userInput.trim()) {
11562
+ error("intent required for the first draft (no spec exists yet)");
11563
+ process.exit(1);
11564
+ }
11565
+ const turnResult = await runOneTurn({
11566
+ featureName,
11567
+ specName,
11568
+ existing,
11569
+ userInput: userInput.trim(),
11570
+ autoApply: opts.apply === true,
11571
+ language: opts.language
11572
+ });
11573
+ if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
11574
+ blank();
11575
+ if (/^y/i.test(await prompt(ja ? "このドラフトは完了ですか? [y/N] " : "Are you done with this draft? [y/N] "))) {
11576
+ info("draft session complete.");
11577
+ hint(`run 'ccqa trace ${featureName}/${specName}' to record actions`);
11578
+ process.exit(0);
11579
+ }
11595
11580
  }
11596
11581
  }
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
11582
+ async function runOneTurn(input) {
11583
+ const { featureName, specName, existing, userInput, autoApply, language } = input;
11584
+ const isFirstRun = existing === null;
11585
+ const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks()) + languageDirective(language);
11586
+ const userPrompt = buildDraftPrompt({
11587
+ mode: isFirstRun ? "create" : "refine",
11588
+ existing: existing ?? "",
11589
+ userInput
11614
11590
  });
11615
- const hubClient = resolveHubClient({
11616
- hubUrl: opts.hubUrl,
11617
- hubToken: opts.hubToken,
11618
- hubHeader: opts.hubHeader
11591
+ info(isFirstRun ? "Reading codebase and drafting spec..." : "Re-validating spec against codebase...");
11592
+ const toolCounts = {};
11593
+ const startedAt = Date.now();
11594
+ const { result, isError } = await invokeClaudeStreaming({
11595
+ prompt: userPrompt,
11596
+ systemPrompt,
11597
+ allowedTools: [
11598
+ "Read",
11599
+ "Grep",
11600
+ "Glob"
11601
+ ],
11602
+ silenceBashLog: true
11603
+ }, (msg) => {
11604
+ if (msg.type !== "assistant") return;
11605
+ for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
11619
11606
  });
11620
- const hubContext = hubClient && project ? {
11621
- hub: hubClient,
11622
- project
11623
- } : null;
11607
+ printToolSummary(toolCounts, Date.now() - startedAt);
11608
+ if (isError) {
11609
+ error("Claude returned an error result");
11610
+ return {
11611
+ hasError: true,
11612
+ applied: false
11613
+ };
11614
+ }
11615
+ const json = extractJsonBlock(result);
11616
+ if (!json) {
11617
+ error("Claude did not return a json block");
11618
+ warn(`raw tail: ${truncate(result, 200)}`);
11619
+ return {
11620
+ hasError: true,
11621
+ applied: false
11622
+ };
11623
+ }
11624
+ let report;
11624
11625
  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
- });
11626
+ report = DraftReportSchema.parse(JSON.parse(json));
11636
11627
  } catch (e) {
11637
- if (e instanceof SpecLockedError) {
11638
- error(e.message);
11639
- process.exit(2);
11640
- }
11641
- throw e;
11628
+ error(`failed to parse draft report: ${e.message}`);
11629
+ return {
11630
+ hasError: true,
11631
+ applied: false
11632
+ };
11642
11633
  }
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);
11634
+ const hasError = printReviewBlock(report.issues);
11635
+ const original = existing ?? "";
11636
+ if (!report.patch || report.patch === original) {
11637
+ blank();
11638
+ info("no changes proposed.");
11639
+ return {
11640
+ hasError,
11641
+ applied: false
11642
+ };
11656
11643
  }
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);
11644
+ blank();
11645
+ info("--- proposed changes ---");
11646
+ printUnifiedDiff(original, report.patch);
11647
+ blank();
11648
+ if (!(autoApply ? true : /^y/i.test(await prompt(useJapanesePrompts(language) ? "このパッチを適用しますか? [y/N] " : "Apply this patch? [y/N] ")))) {
11649
+ info("aborted — no changes applied.");
11650
+ return {
11651
+ hasError,
11652
+ applied: false
11653
+ };
11662
11654
  }
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
11655
+ try {
11656
+ parseTestSpec(report.patch);
11657
+ } catch (e) {
11658
+ error(`refused to apply: patch failed validation (${e.message})`);
11659
+ return {
11660
+ hasError: true,
11661
+ applied: false
11662
+ };
11663
+ }
11664
+ meta("saved", await saveSpecFile(featureName, specName, report.patch));
11665
+ return {
11666
+ hasError,
11667
+ applied: true
11668
+ };
11669
+ }
11670
+ async function prompt(question) {
11671
+ const rl = createInterface$1({
11672
+ input: process.stdin,
11673
+ output: process.stdout
11681
11674
  });
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;
11675
+ rl.on("SIGINT", () => {
11676
+ rl.close();
11677
+ process.exit(130);
11692
11678
  });
11693
- let traceResult = null;
11694
11679
  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
- });
11680
+ return (await rl.question(question)).trim();
11712
11681
  } 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
- });
11682
+ rl.close();
11725
11683
  }
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")}`;
11684
+ }
11685
+ /** Aggregated tool-call counts shown after each Claude turn. */
11686
+ function formatToolSummary(counts, elapsedMs) {
11687
+ const entries = Object.entries(counts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).map(([name, n]) => `${n} ${name}`);
11688
+ return ` ✓ ${entries.length === 0 ? "no tool calls" : entries.join(", ")} (${(elapsedMs / 1e3).toFixed(1)}s)`;
11689
+ }
11690
+ function printToolSummary(counts, elapsedMs) {
11691
+ process.stdout.write(`${formatToolSummary(counts, elapsedMs)}\n`);
11762
11692
  }
11763
11693
  /**
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.
11694
+ * Renders the review report as a visually separated block, grouped by
11695
+ * severity. ERROR and WARN findings get full detail; OK findings collapse
11696
+ * to a one-line summary of category names. Returns whether any ERROR
11697
+ * severity was emitted.
12029
11698
  */
12030
11699
  function printReviewBlock(issues) {
12031
11700
  const RULE = "─".repeat(67);
@@ -12228,1013 +11897,1361 @@ function truncate(s, n) {
12228
11897
  return s.slice(s.length - n);
12229
11898
  }
12230
11899
  //#endregion
12231
- //#region src/drift/format.ts
11900
+ //#region src/cli/perspectives.ts
11901
+ 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) => {
11902
+ if (opts.check) await runPerspectivesCheck(opts);
11903
+ else await runPerspectives(opts);
11904
+ }));
12232
11905
  /**
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.
11906
+ * `--check`: compare the hub document against a freshly-built local skeleton
11907
+ * on the CLI-owned mechanical fields only (the spec set, titles,
11908
+ * relatedPaths, status). Claude-authored descriptive fields and the human
11909
+ * note are deliberately not compared — they are not deterministic, so they
11910
+ * can't signal staleness. Exit 1 on any mismatch; this is the CI gate for
11911
+ * "someone changed the specs without the inventory catching up".
12236
11912
  */
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}`);
11913
+ async function runPerspectivesCheck(opts) {
11914
+ const hub = requireHubOrExit(opts);
11915
+ const project = resolveProject(opts);
11916
+ header("perspectives", `check (project: ${project})`);
11917
+ const skeleton = await buildSkeleton(await listFeatureTree());
11918
+ const localCount = skeleton.reduce((n, f) => n + f.specs.length, 0);
11919
+ const existingDoc = await hub.getPerspectives(project);
11920
+ if (existingDoc === null) {
11921
+ if (localCount === 0) {
11922
+ info("no local test cases and no hub document — nothing to check.");
11923
+ return;
12269
11924
  }
11925
+ error(`no perspectives document on the hub for project "${project}" — run \`ccqa perspectives\` to create it`);
11926
+ process.exit(1);
12270
11927
  }
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`;
11928
+ const parsed = PerspectivesSchema.safeParse(existingDoc);
11929
+ if (!parsed.success) {
11930
+ error("the hub document does not match the perspectives schema — run `ccqa perspectives` to regenerate it");
11931
+ process.exit(1);
11932
+ }
11933
+ info(`checking ${localCount} local test case(s) against the hub document...`);
11934
+ const issues = comparePerspectivesSkeleton(skeleton, parsed.data);
11935
+ if (issues.length === 0) {
11936
+ blank();
11937
+ info(`perspectives are up to date (${localCount} case(s)).`);
11938
+ return;
11939
+ }
11940
+ blank();
11941
+ for (const issue of issues) error(issue);
11942
+ blank();
11943
+ error(`perspectives are stale (${issues.length} issue(s)) — run \`ccqa perspectives\` to regenerate`);
11944
+ process.exit(1);
12301
11945
  }
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)}`);
11946
+ /**
11947
+ * Mechanical-field comparison behind `--check`. Returns one human-readable
11948
+ * line per out-of-sync spec (empty when in sync). Exported for unit testing.
11949
+ */
11950
+ function comparePerspectivesSkeleton(local, remote) {
11951
+ const remoteMap = /* @__PURE__ */ new Map();
11952
+ for (const feature of remote.features) for (const spec of feature.specs) remoteMap.set(noteKey(feature.featureName, spec.specName), spec);
11953
+ const issues = [];
11954
+ const seen = /* @__PURE__ */ new Set();
11955
+ for (const feature of local) for (const spec of feature.specs) {
11956
+ const key = noteKey(feature.featureName, spec.specName);
11957
+ seen.add(key);
11958
+ const remoteSpec = remoteMap.get(key);
11959
+ if (!remoteSpec) {
11960
+ issues.push(`${key}: not in the hub document`);
12309
11961
  continue;
12310
11962
  }
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)}`);
11963
+ const fields = [];
11964
+ if (remoteSpec.title !== spec.title) fields.push("title");
11965
+ if (!sameStringArray(remoteSpec.relatedPaths, spec.relatedPaths)) fields.push("relatedPaths");
11966
+ 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)})`);
11967
+ if (fields.length > 0) issues.push(`${key}: out of date — ${fields.join(", ")}`);
11968
+ }
11969
+ for (const key of remoteMap.keys()) if (!seen.has(key)) issues.push(`${key}: no longer exists locally (stale hub entry)`);
11970
+ return issues;
11971
+ }
11972
+ function formatStatus(status) {
11973
+ return `${status.mode}/traced=${status.traced}/generated=${status.generated}`;
11974
+ }
11975
+ /** Order-sensitive equality; an absent list and an empty list are the same thing. */
11976
+ function sameStringArray(a, b) {
11977
+ const left = a ?? [];
11978
+ const right = b ?? [];
11979
+ return left.length === right.length && left.every((v, i) => v === right[i]);
11980
+ }
11981
+ /** Perspectives live on the hub only — no hub, no place to store (or check) them. */
11982
+ function requireHubOrExit(opts) {
11983
+ try {
11984
+ return requireHubClient(opts);
11985
+ } catch (err) {
11986
+ if (err instanceof HubConnectionError) {
11987
+ error(err.message);
11988
+ hint("perspectives are stored on the hub — start one with `ccqa serve`");
11989
+ process.exit(2);
12317
11990
  }
11991
+ throw err;
12318
11992
  }
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
- }
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
11993
  }
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++;
11994
+ async function runPerspectives(opts) {
11995
+ const hub = requireHubOrExit(opts);
11996
+ const project = resolveProject(opts);
11997
+ header("perspectives", `project: ${project}`);
11998
+ const skeleton = await buildSkeleton(await listFeatureTree());
11999
+ const allSpecs = skeleton.flatMap((f) => f.specs);
12000
+ if (allSpecs.length === 0) {
12001
+ info("no test cases found under .ccqa/features nothing to inventory.");
12002
+ return;
12342
12003
  }
12343
- return {
12344
- error,
12345
- warn,
12346
- ok,
12347
- errored
12348
- };
12004
+ const existingDoc = await hub.getPerspectives(project);
12005
+ const noteMap = extractNotes(existingDoc);
12006
+ const specBodies = await loadSpecBodies(skeleton);
12007
+ meta("language", opts.language ?? "auto");
12008
+ info(`Summarising ${allSpecs.length} test case(s) across ${skeleton.length} feature(s)...`);
12009
+ const summaries = await requestSummaries(specBodies, opts);
12010
+ if (summaries === null) process.exit(1);
12011
+ const merged = mergePerspectives(skeleton, summaries, noteMap);
12012
+ let validated;
12013
+ try {
12014
+ validated = PerspectivesSchema.parse(merged);
12015
+ } catch (e) {
12016
+ error(`refused to push: assembled perspectives failed validation (${e.message})`);
12017
+ process.exit(1);
12018
+ }
12019
+ const existingYaml = existingDoc === null ? "" : stringify(existingDoc, { lineWidth: 0 });
12020
+ const next = stringify(validated, { lineWidth: 0 });
12021
+ if (withoutGeneratedAt(existingYaml) === withoutGeneratedAt(next)) {
12022
+ blank();
12023
+ info("perspectives already up to date — no changes.");
12024
+ await cleanupLegacyLocalFiles();
12025
+ return;
12026
+ }
12027
+ blank();
12028
+ info("--- proposed changes (YAML view of the hub document) ---");
12029
+ printUnifiedDiff(existingYaml, next);
12030
+ blank();
12031
+ if (!(opts.apply === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "hub に perspectives を保存しますか? [y/N] " : "Push perspectives to the hub? [y/N] ")))) {
12032
+ info("aborted — no changes written.");
12033
+ return;
12034
+ }
12035
+ await hub.putPerspectives(project, validated);
12036
+ meta("pushed", `perspectives (project: ${project})`);
12037
+ await cleanupLegacyLocalFiles();
12349
12038
  }
12350
- //#endregion
12351
- //#region src/drift/exit-code.ts
12352
12039
  /**
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`.
12040
+ * Perspectives used to be written into the repo (`.ccqa/perspectives.yaml`,
12041
+ * `.ccqa/perspectives.md`, `.ccqa/features/<f>/perspectives.md`). Now that
12042
+ * the document is hub-only, sweep those leftovers whenever the command runs
12043
+ * so consuming repos converge without a manual cleanup.
12356
12044
  */
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;
12045
+ async function cleanupLegacyLocalFiles() {
12046
+ const removed = await removeLegacyPerspectivesFiles();
12047
+ for (const path of removed) meta("removed legacy file", path);
12366
12048
  }
12367
12049
  /**
12368
- * Spec-level status under the given threshold, mirroring determineExitCode's
12369
- * per-issue logic (exit-code.ts) but scoped to a single SpecResult.
12050
+ * Turn the feature tree into the skeleton perspectives features: title +
12051
+ * relatedPaths transcribed from each spec, status derived mechanically from
12052
+ * on-disk artifacts. `summary` is left empty here; Claude fills it later.
12053
+ * Specs whose spec.yaml is missing or unparsable are skipped.
12370
12054
  */
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";
12055
+ async function buildSkeleton(tree) {
12056
+ return (await Promise.all(tree.map(async (feature) => {
12057
+ const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
12058
+ const spec = await readSpecMeta(feature.featureName, s.specName);
12059
+ const status = await deriveStatus(feature.featureName, s.specName, spec.mode);
12060
+ const entry = {
12061
+ specName: s.specName,
12062
+ title: spec.title,
12063
+ summary: "",
12064
+ status
12065
+ };
12066
+ if (s.relatedPaths) entry.relatedPaths = s.relatedPaths;
12067
+ return entry;
12068
+ }));
12069
+ return {
12070
+ featureName: feature.featureName,
12071
+ specs
12072
+ };
12073
+ }))).filter((f) => f.specs.length > 0).map((f) => ({
12074
+ featureName: f.featureName,
12075
+ specs: [...f.specs].sort((a, b) => a.specName.localeCompare(b.specName))
12076
+ })).sort((a, b) => a.featureName.localeCompare(b.featureName));
12378
12077
  }
12379
12078
  /**
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.
12079
+ * `(featureName, specName)` human note, extracted from the hub's current
12080
+ * perspectives document. Notes are preserved across regeneration; everything
12081
+ * else (title, status, summary) is recomputed. Returns an empty map when the
12082
+ * document is absent or doesn't match the schema note preservation is
12083
+ * best-effort and never blocks regeneration.
12384
12084
  */
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
12085
+ function extractNotes(existing) {
12086
+ const map = /* @__PURE__ */ new Map();
12087
+ if (existing === null || existing === void 0) return map;
12088
+ const result = PerspectivesSchema.safeParse(existing);
12089
+ if (!result.success) return map;
12090
+ 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);
12091
+ return map;
12092
+ }
12093
+ /**
12094
+ * Merge the mechanical skeleton with Claude's summaries and the preserved
12095
+ * notes into the final perspectives object. Summaries are matched by
12096
+ * (featureName, specName); an unmatched spec keeps its empty summary.
12097
+ */
12098
+ function mergePerspectives(skeleton, summaries, noteMap) {
12099
+ const summaryMap = /* @__PURE__ */ new Map();
12100
+ for (const s of summaries) summaryMap.set(noteKey(s.featureName, s.specName), s);
12101
+ const features = skeleton.map((feature) => ({
12102
+ featureName: feature.featureName,
12103
+ specs: feature.specs.map((spec) => {
12104
+ const key = noteKey(feature.featureName, spec.specName);
12105
+ const entry = summaryMap.get(key);
12106
+ const merged = {
12107
+ ...spec,
12108
+ summary: entry?.summary ?? spec.summary
12109
+ };
12110
+ if (entry?.startScreen) merged.startScreen = entry.startScreen;
12111
+ if (entry?.testCondition) merged.testCondition = entry.testCondition;
12112
+ if (entry?.preconditions && entry.preconditions.length > 0) merged.preconditions = entry.preconditions;
12113
+ const note = noteMap.get(key);
12114
+ if (note !== void 0) merged.note = note;
12115
+ return merged;
12116
+ })
12402
12117
  }));
12403
12118
  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
12119
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12120
+ features
12414
12121
  };
12415
12122
  }
12416
- //#endregion
12417
- //#region src/drift/route-new-files.ts
12418
12123
  /**
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.
12124
+ * Strip the top-level `generatedAt:` line so two serialised perspectives can
12125
+ * be compared for substantive equality without the always-fresh timestamp
12126
+ * defeating the "already up to date" check. Exported for unit testing.
12428
12127
  */
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;
12128
+ function withoutGeneratedAt(yamlText) {
12129
+ return yamlText.split("\n").filter((line) => !/^generatedAt:/.test(line)).join("\n").trim();
12130
+ }
12131
+ function noteKey(featureName, specName) {
12132
+ return `${featureName}/${specName}`;
12133
+ }
12134
+ async function readSpecMeta(featureName, specName) {
12135
+ const raw = await tryReadSpecFile(featureName, specName);
12136
+ if (raw === null) return {
12137
+ title: specName,
12138
+ mode: DEFAULT_SPEC_MODE
12139
+ };
12140
+ try {
12141
+ const parsed = parse(raw);
12142
+ const title = typeof parsed.title === "string" && parsed.title.length > 0 ? parsed.title : specName;
12143
+ const modeResult = SpecModeSchema.safeParse(parsed.mode);
12144
+ return {
12145
+ title,
12146
+ mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
12147
+ };
12148
+ } catch {
12149
+ return {
12150
+ title: specName,
12151
+ mode: DEFAULT_SPEC_MODE
12152
+ };
12153
+ }
12154
+ }
12155
+ async function deriveStatus(featureName, specName, mode) {
12156
+ return {
12157
+ mode,
12158
+ traced: await stat(join(getSpecDir(featureName, specName), "ir.json")).then(() => true).catch(() => false),
12159
+ generated: await getTestScript(featureName, specName) !== null
12160
+ };
12161
+ }
12162
+ async function loadSpecBodies(skeleton) {
12163
+ return await Promise.all(skeleton.flatMap((feature) => feature.specs.map(async (spec) => {
12164
+ const specYaml = await tryReadSpecFile(feature.featureName, spec.specName) ?? "";
12165
+ return {
12166
+ featureName: feature.featureName,
12167
+ specName: spec.specName,
12168
+ title: spec.title,
12169
+ specYaml
12170
+ };
12171
+ })));
12172
+ }
12173
+ async function requestSummaries(specs, opts) {
12174
+ const toolCounts = {};
12175
+ const startedAt = Date.now();
12433
12176
  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(),
12177
+ prompt: buildPerspectivesPrompt(specs, opts.instruction),
12178
+ systemPrompt: buildPerspectivesSystemPrompt() + languageDirective(opts.language),
12439
12179
  allowedTools: [
12440
12180
  "Read",
12441
12181
  "Grep",
12442
12182
  "Glob"
12443
12183
  ],
12444
12184
  silenceBashLog: true,
12445
- cwd,
12446
- ...model ? { model } : {}
12447
- }, (_msg) => {});
12185
+ ...opts.model ? { model: opts.model } : {}
12186
+ }, (msg) => {
12187
+ if (msg.type !== "assistant") return;
12188
+ for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
12189
+ });
12190
+ process.stdout.write(`${formatToolSummary(toolCounts, Date.now() - startedAt)}\n`);
12448
12191
  if (isError) {
12449
- warn("new-file router: Claude returned an error; skipping router signal");
12450
- return empty;
12192
+ error("Claude returned an error result");
12193
+ return null;
12451
12194
  }
12452
12195
  const json = extractJsonBlock(result);
12453
12196
  if (!json) {
12454
- warn("new-file router: no JSON block in response; skipping router signal");
12455
- return empty;
12197
+ error("Claude did not return a json block");
12198
+ return null;
12456
12199
  }
12457
- let parsed;
12200
+ return parseSummaries(json);
12201
+ }
12202
+ /**
12203
+ * Parse the `{ summaries: [...] }` JSON contract into typed entries. Returns
12204
+ * null and logs when the payload is malformed. Exported for unit testing.
12205
+ */
12206
+ function parseSummaries(json) {
12207
+ let payload;
12458
12208
  try {
12459
- parsed = JSON.parse(json);
12209
+ payload = JSON.parse(json);
12460
12210
  } catch (e) {
12461
- warn(`new-file router: failed to parse JSON (${e.message}); skipping router signal`);
12462
- return empty;
12211
+ error(`failed to parse summaries JSON: ${e.message}`);
12212
+ return null;
12463
12213
  }
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);
12214
+ if (typeof payload !== "object" || payload === null) {
12215
+ error("summaries payload is not an object");
12216
+ return null;
12217
+ }
12218
+ const summaries = payload.summaries;
12219
+ if (!Array.isArray(summaries)) {
12220
+ error("summaries payload missing a `summaries` array");
12221
+ return null;
12222
+ }
12223
+ const out = [];
12224
+ for (const item of summaries) {
12225
+ const rec = item ?? {};
12226
+ const { featureName, specName, summary } = rec;
12227
+ if (typeof featureName === "string" && typeof specName === "string" && typeof summary === "string") {
12228
+ const entry = {
12229
+ featureName,
12230
+ specName,
12231
+ summary
12232
+ };
12233
+ if (typeof rec.startScreen === "string" && rec.startScreen.length > 0) entry.startScreen = rec.startScreen;
12234
+ if (typeof rec.testCondition === "string" && rec.testCondition.length > 0) entry.testCondition = rec.testCondition;
12235
+ if (Array.isArray(rec.preconditions)) {
12236
+ const pre = rec.preconditions.filter((p) => typeof p === "string" && p.length > 0);
12237
+ if (pre.length > 0) entry.preconditions = pre;
12238
+ }
12239
+ out.push(entry);
12470
12240
  }
12471
12241
  }
12472
12242
  return out;
12473
12243
  }
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"]
12502
- }
12503
- \`\`\`
12504
-
12505
- Use exactly the keys you saw in the input ("<feature>/<spec>"). Return an empty array if no spec is affected.
12506
- `;
12507
- }
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
- `;
12527
- }
12528
12244
  //#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);
12546
- }
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");
12559
- }
12560
- const blocks = await loadAvailableBlocks(cwd);
12561
- const results = await analyzeDrift({
12562
- targets,
12563
- 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}`);
12570
- }
12571
- });
12572
- process.stdout.write(renderDrift(results, format, cwd));
12573
- if (opts.push) await pushDriftResults({
12574
- results,
12575
- threshold,
12576
- cwd,
12577
- opts,
12578
- format,
12579
- baseRef
12580
- });
12581
- process.exit(determineExitCode(results, threshold));
12582
- });
12245
+ //#region src/cli/perspectives-sync.ts
12583
12246
  /**
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).
12247
+ * Best-effort incremental update of the hub-stored perspectives document
12248
+ * after a successful `ccqa record` / `ccqa generate`: upsert just this spec's
12249
+ * entry (mechanical facts recomputed, descriptive fields rewritten by one
12250
+ * small Claude call, the human `note` preserved). The full `ccqa perspectives`
12251
+ * run remains the way to regenerate everything and prune deleted specs.
12588
12252
  *
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.
12253
+ * Never fails the caller: no hub configured no-op; any error → warning.
12591
12254
  */
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
- }
12255
+ async function syncSpecPerspectives(hubContext, opts) {
12256
+ if (!hubContext) return;
12599
12257
  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
- }
12611
- });
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
- }
12258
+ await doSync(hubContext, opts);
12628
12259
  } catch (err) {
12629
- if (err instanceof HubApiError) {
12630
- error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
12631
- process.exit(2);
12632
- }
12633
- throw err;
12260
+ warn(`perspectives auto-update skipped: ${err instanceof Error ? err.message : String(err)}`);
12634
12261
  }
12635
12262
  }
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);
12263
+ async function doSync(ctx, opts) {
12264
+ const { featureName, specName } = opts.ref;
12265
+ const specYaml = await tryReadSpecFile(featureName, specName);
12266
+ if (specYaml === null) return;
12267
+ const existing = await ctx.hub.getPerspectives(ctx.project);
12268
+ let doc;
12269
+ if (existing === null) doc = {
12270
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12271
+ features: []
12272
+ };
12273
+ else {
12274
+ const parsed = PerspectivesSchema.safeParse(existing);
12275
+ if (!parsed.success) {
12276
+ warn("perspectives auto-update skipped: the hub document does not match the schema — run `ccqa perspectives` to regenerate it");
12277
+ return;
12278
+ }
12279
+ doc = parsed.data;
12280
+ }
12281
+ const meta$1 = await readSpecMeta(featureName, specName);
12282
+ const status = await deriveStatus(featureName, specName, meta$1.mode);
12283
+ const relatedPaths = extractRelatedPaths(specYaml);
12284
+ const previous = findSpec(doc, featureName, specName);
12285
+ const written = (await requestSummaries([{
12286
+ featureName,
12287
+ specName,
12288
+ title: meta$1.title,
12289
+ specYaml
12290
+ }], {
12291
+ ...opts.language ? { language: opts.language } : {},
12292
+ ...opts.model ? { model: opts.model } : {}
12293
+ }))?.[0];
12294
+ const entry = {
12295
+ specName,
12296
+ title: meta$1.title,
12297
+ summary: written?.summary ?? previous?.summary ?? "",
12298
+ status
12299
+ };
12300
+ const startScreen = written?.startScreen ?? previous?.startScreen;
12301
+ if (startScreen) entry.startScreen = startScreen;
12302
+ const testCondition = written?.testCondition ?? previous?.testCondition;
12303
+ if (testCondition) entry.testCondition = testCondition;
12304
+ const preconditions = written?.preconditions ?? previous?.preconditions;
12305
+ if (preconditions && preconditions.length > 0) entry.preconditions = preconditions;
12306
+ if (relatedPaths.length > 0) entry.relatedPaths = relatedPaths;
12307
+ if (previous?.note) entry.note = previous.note;
12308
+ upsertSpec(doc, featureName, entry);
12309
+ doc.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
12310
+ await ctx.hub.putPerspectives(ctx.project, PerspectivesSchema.parse(doc));
12311
+ meta("perspectives", `updated ${featureName}/${specName} on the hub`);
12312
+ }
12313
+ function findSpec(doc, featureName, specName) {
12314
+ return doc.features.find((f) => f.featureName === featureName)?.specs.find((s) => s.specName === specName);
12315
+ }
12316
+ /** Replace-or-insert the spec entry, keeping features and specs name-sorted like a full regeneration. */
12317
+ function upsertSpec(doc, featureName, entry) {
12318
+ let feature = doc.features.find((f) => f.featureName === featureName);
12319
+ if (!feature) {
12320
+ feature = {
12321
+ featureName,
12322
+ specs: []
12323
+ };
12324
+ doc.features.push(feature);
12325
+ doc.features.sort((a, b) => a.featureName.localeCompare(b.featureName));
12326
+ }
12327
+ const idx = feature.specs.findIndex((s) => s.specName === entry.specName);
12328
+ if (idx >= 0) feature.specs[idx] = entry;
12329
+ else {
12330
+ feature.specs.push(entry);
12331
+ feature.specs.sort((a, b) => a.specName.localeCompare(b.specName));
12332
+ }
12640
12333
  }
12641
- async function filterByChanged(input) {
12642
- const { targets, cwd, baseOverride, format, model } = input;
12643
- const base = resolveBaseRef(baseOverride);
12644
- let changed;
12334
+ /** `relatedPaths` transcribed verbatim from the spec.yaml (empty when absent/unparsable). */
12335
+ function extractRelatedPaths(specYaml) {
12645
12336
  try {
12646
- changed = await getChangedFiles(base, cwd);
12647
- } catch (e) {
12648
- error(`failed to run 'git diff' against ${base}: ${e.message}`);
12649
- process.exit(2);
12337
+ const parsed = parse(specYaml);
12338
+ if (!Array.isArray(parsed?.relatedPaths)) return [];
12339
+ return parsed.relatedPaths.filter((p) => typeof p === "string" && p.length > 0);
12340
+ } catch {
12341
+ return [];
12650
12342
  }
12651
- if (format === "text") {
12652
- meta("changed-base", base);
12653
- meta("changed-files", changed.length);
12343
+ }
12344
+ //#endregion
12345
+ //#region src/cli/generate.ts
12346
+ const AUTO_FIX_MODES = [
12347
+ "interactive",
12348
+ "auto",
12349
+ "skip"
12350
+ ];
12351
+ function toFixMode(autoFix) {
12352
+ switch (autoFix) {
12353
+ case "auto": return "auto";
12354
+ case "skip": return "non-interactive";
12355
+ case "interactive": return "interactive";
12654
12356
  }
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);
12357
+ }
12358
+ /** Shared `--auto-fix` parser for the record / generate commands. */
12359
+ function parseAutoFixFlag(raw) {
12360
+ if (AUTO_FIX_MODES.includes(raw)) return raw;
12361
+ throw new Error(`--auto-fix must be one of ${AUTO_FIX_MODES.join(" | ")}`);
12362
+ }
12363
+ /**
12364
+ * The `generate` flow shared by `ccqa generate` and the codegen half of
12365
+ * `ccqa record`: resolve the spec's target plugin, load its input (the
12366
+ * recording, for input:"recording" targets), and dispatch to the plugin.
12367
+ * This layer owns the CLI concerns — overwrite confirmation, logging,
12368
+ * exit-code policy — while the plugin owns the generation pipeline.
12369
+ */
12370
+ async function runGenerate(featureName, specName, opts) {
12371
+ header("generate", `${featureName}/${specName}`);
12372
+ const cwd = opts.cwd ?? process.cwd();
12373
+ await ensureCcqaDir(cwd);
12374
+ const releaseLock = await acquireSpecLock(featureName, specName, "generate", cwd);
12375
+ try {
12376
+ await runGenerateLocked(featureName, specName, opts, cwd);
12377
+ } finally {
12378
+ await releaseLock();
12664
12379
  }
12665
- for (const t of targets) {
12666
- if (!t.relatedPaths) {
12667
- affected.add(specKey(t));
12668
- continue;
12669
- }
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;
12380
+ }
12381
+ async function runGenerateLocked(featureName, specName, opts, cwd) {
12382
+ const specYaml = await readSpecFile(featureName, specName, cwd);
12383
+ const spec = parseTestSpec(specYaml);
12384
+ const config = await loadProjectConfig(cwd);
12385
+ const target = opts.targetOverride !== void 0 ? resolveTargetOverride(spec, opts.targetOverride) : resolveTarget(spec, config);
12386
+ meta("target", target.id + (opts.targetOverride !== void 0 ? " (--target override)" : ""));
12387
+ const existingOutput = await target.existingOutput?.({
12388
+ featureName,
12389
+ specName
12390
+ }, cwd) ?? null;
12391
+ if (existingOutput && !opts.force) {
12392
+ if (!await confirmOverwrite(existingOutput)) {
12393
+ info("aborted; pass --force to overwrite without prompting");
12394
+ return;
12673
12395
  }
12674
- if (t.includedBlocks?.some((name) => touchedBlockNames.has(name))) affected.add(specKey(t));
12675
12396
  }
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
12687
- });
12688
- for (const key of routed) affected.add(key);
12397
+ let recording;
12398
+ if (target.input === "recording") {
12399
+ const { path: recordingPath, actions } = await getRecording(featureName, specName, cwd);
12400
+ meta("recording", recordingPath);
12401
+ meta("actions", actions.length);
12402
+ recording = actions;
12689
12403
  }
12690
- return targets.filter((t) => affected.has(specKey(t)));
12691
- }
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);
12404
+ await warnStaleBlockArtifacts();
12405
+ const targetConfig = config.targets[target.id] ?? TargetConfigSchema.parse({});
12406
+ const ctx = {
12407
+ spec,
12408
+ specYaml,
12409
+ featureName,
12410
+ specName,
12411
+ cwd,
12412
+ recording,
12413
+ resources: targetConfig.resources,
12414
+ conventions: targetConfig.conventions,
12415
+ targetConfig,
12416
+ language: opts.language,
12417
+ model: opts.model,
12418
+ hub: opts.hubContext ?? null,
12419
+ fix: {
12420
+ maxRetries: opts.maxRetries,
12421
+ mode: opts.fixMode,
12422
+ useSnapshot: opts.useSnapshot
12700
12423
  }
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);
12424
+ };
12425
+ if (!(await target.generate(ctx)).passed) {
12426
+ warn("auto-fix exhausted; test still failing");
12427
+ process.exit(1);
12717
12428
  }
12718
- return out;
12719
- }
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);
12725
- }
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);
12429
+ hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
12731
12430
  }
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);
12431
+ async function confirmOverwrite(path) {
12432
+ if (!process.stdin.isTTY) {
12433
+ warn(`${path} exists and stdin is not a TTY; refusing to overwrite. Pass --force to allow.`);
12434
+ return false;
12435
+ }
12436
+ const rl = createInterface({
12437
+ input: process.stdin,
12438
+ output: process.stdout
12439
+ });
12440
+ try {
12441
+ process.stdout.write("\n");
12442
+ process.stdout.write(`[warn] ${path} already exists.\n`);
12443
+ process.stdout.write(`[warn] generate will regenerate it and any manual edits will be lost.\n`);
12444
+ const norm = (await new Promise((res) => rl.question("Overwrite? [y/N] ", res))).trim().toLowerCase();
12445
+ return norm === "y" || norm === "yes";
12446
+ } finally {
12447
+ rl.close();
12738
12448
  }
12739
- return n;
12740
12449
  }
12741
- //#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) => {
12450
+ 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) => {
12451
+ const { featureName, specName } = parseSpecPath(specPath);
12452
+ const language = opts.language ?? "auto";
12744
12453
  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
12454
+ const hubClient = resolveHubClient({
12455
+ hubUrl: opts.hubUrl,
12456
+ hubToken: opts.hubToken,
12457
+ hubHeader: opts.hubHeader
12458
+ });
12459
+ const project = opts.profile !== void 0 || hubClient !== null ? resolveProject(opts) : void 0;
12460
+ if (opts.profile !== void 0) await applyProfileFromOption({
12461
+ profile: opts.profile,
12462
+ project,
12463
+ cwd,
12464
+ hubUrl: opts.hubUrl,
12465
+ hubToken: opts.hubToken,
12466
+ hubHeader: opts.hubHeader
12467
+ });
12468
+ else await applyProfileFromOption({
12469
+ profile: void 0,
12470
+ project: "",
12471
+ cwd
12472
+ });
12473
+ const hubContext = hubClient && project ? {
12474
+ hub: hubClient,
12475
+ project
12476
+ } : null;
12477
+ try {
12478
+ await runGenerate(featureName, specName, {
12479
+ maxRetries: parseInt(opts.maxRetries ?? "3", 10),
12480
+ fixMode: toFixMode(opts.autoFix ?? "interactive"),
12481
+ force: opts.force ?? false,
12482
+ useSnapshot: opts.snapshot !== false,
12483
+ language,
12484
+ model: opts.model,
12485
+ targetOverride: opts.target,
12486
+ cwd,
12487
+ hubContext
12488
+ });
12489
+ } catch (e) {
12490
+ if (e instanceof SpecLockedError) {
12491
+ error(e.message);
12492
+ process.exit(2);
12493
+ }
12494
+ throw e;
12495
+ }
12496
+ await syncSpecPerspectives(hubContext, {
12497
+ ref: {
12498
+ featureName,
12499
+ specName
12500
+ },
12501
+ ...language ? { language } : {},
12502
+ ...opts.model ? { model: opts.model } : {}
12503
+ });
12504
+ });
12505
+ //#endregion
12506
+ //#region src/cli/record.ts
12507
+ const VALIDATION_MODES = ["lenient", "strict"];
12508
+ 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) => {
12509
+ if (VALIDATION_MODES.includes(raw)) return raw;
12510
+ throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
12511
+ }, "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) => {
12512
+ const { featureName, specName } = parseSpecPath(specPath);
12513
+ const language = opts.language ?? "auto";
12514
+ if (opts.skipTrace && opts.skipCodegen) {
12515
+ error("--skip-trace and --skip-codegen cannot be combined; nothing would run");
12516
+ process.exit(2);
12517
+ }
12518
+ const cwdForProfile = resolveCwd(opts.cwd);
12519
+ const target = resolveTarget(parseTestSpec(await readSpecFile(featureName, specName, cwdForProfile)), await loadProjectConfig(cwdForProfile));
12520
+ if (target.input === "spec") {
12521
+ error(`target "${target.id}" does not use a browser recording — run 'ccqa generate ${featureName}/${specName}' instead`);
12522
+ process.exit(2);
12523
+ }
12524
+ const project = opts.profile !== void 0 ? resolveProject(opts) : void 0;
12525
+ if (opts.profile !== void 0) await applyProfileFromOption({
12526
+ profile: opts.profile,
12527
+ project,
12528
+ cwd: cwdForProfile,
12529
+ hubUrl: opts.hubUrl,
12530
+ hubToken: opts.hubToken,
12531
+ hubHeader: opts.hubHeader
12532
+ });
12533
+ else await applyProfileFromOption({
12534
+ profile: void 0,
12535
+ project: "",
12536
+ cwd: cwdForProfile
12537
+ });
12538
+ const hubClientForTrace = resolveHubClient({
12539
+ hubUrl: opts.hubUrl,
12540
+ hubToken: opts.hubToken,
12541
+ hubHeader: opts.hubHeader
12542
+ });
12543
+ const hubProject = project ?? (hubClientForTrace !== null ? resolveProject(opts) : void 0);
12544
+ const hubContext = hubClientForTrace && hubProject ? {
12545
+ hub: hubClientForTrace,
12546
+ project: hubProject
12547
+ } : null;
12548
+ const releaseLock = await acquireSpecLock(featureName, specName, "record", cwdForProfile).catch((e) => {
12549
+ if (e instanceof SpecLockedError) {
12550
+ error(e.message);
12551
+ process.exit(2);
12552
+ }
12553
+ throw e;
12554
+ });
12555
+ let traceResult = null;
12556
+ try {
12557
+ if (!opts.skipTrace) {
12558
+ traceResult = await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient", language, {
12559
+ cwd: cwdForProfile,
12560
+ hubContext
12561
+ });
12562
+ blank();
12563
+ }
12564
+ if (!opts.skipCodegen) await runGenerate(featureName, specName, {
12565
+ maxRetries: parseInt(opts.maxRetries ?? "3", 10),
12566
+ fixMode: toFixMode(opts.autoFix ?? "interactive"),
12567
+ force: opts.force ?? false,
12568
+ useSnapshot: opts.snapshot !== false,
12569
+ language,
12570
+ model: opts.model,
12571
+ cwd: cwdForProfile,
12572
+ hubContext
12573
+ });
12574
+ } finally {
12575
+ await releaseLock();
12576
+ }
12577
+ if (opts.updateAgentPrompt) if (traceResult === null) warn("--update-agent-prompt is ignored when --skip-trace is set (no run summary available)");
12578
+ else {
12579
+ blank();
12580
+ await updateAgentPrompt({
12581
+ mode: "record",
12582
+ runSummary: buildRecordRunSummary(featureName, specName, traceResult),
12583
+ hubContext,
12584
+ ...opts.model ? { model: opts.model } : {},
12585
+ ...language ? { language } : {}
12586
+ });
12587
+ }
12588
+ await syncSpecPerspectives(hubContext, {
12589
+ ref: {
12590
+ featureName,
12591
+ specName
12592
+ },
12593
+ ...language ? { language } : {},
12594
+ ...opts.model ? { model: opts.model } : {}
12595
+ });
12596
+ });
12755
12597
  /**
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.
12598
+ * Compact summary of the trace pass for the record agent-prompt refresh.
12599
+ * Steps are reconstructed from the trace's status-line protocol (STEP_START
12600
+ * gives the title, STEP_DONE / ASSERTION_FAILED / STEP_SKIPPED the outcome),
12601
+ * and each step carries its per-action observations plus the **concrete kept
12602
+ * commands** — the selectors that actually survived scrub / dedup /
12603
+ * validation. The record playbook is told to record canonical selectors, so
12604
+ * it needs those exact tokens; the prose alone doesn't carry them. The
12605
+ * header's kept/recorded totals flag how much the run thrashed through
12606
+ * selectors overall.
12760
12607
  */
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
- ]
12608
+ function buildRecordRunSummary(featureName, specName, t) {
12609
+ const header = `## ${featureName}/${specName} ${t.status}\nActions: ${t.actionsKept} kept / ${t.actionsRecorded} recorded`;
12610
+ const steps = collectStepSummaries(t.statusLines);
12611
+ if (steps.length === 0) return `${header}\n\n(no step status lines recorded)`;
12612
+ const commandsByStep = groupCommandsByStep(t.actions);
12613
+ const observationsByStep = groupObservationsByStep(t.actions);
12614
+ const unstableByStep = countUnstableByStep(t.actions);
12615
+ return `${header}\n\n${steps.map((s) => {
12616
+ const cmds = commandsByStep.get(s.stepId) ?? [];
12617
+ const observations = observationsByStep.get(s.stepId) ?? [];
12618
+ const churn = t.churnByStep.get(s.stepId);
12619
+ const dropped = churn ? churn.recorded - churn.kept : 0;
12620
+ const redundant = churn?.redundant ?? 0;
12621
+ const unstable = unstableByStep.get(s.stepId) ?? 0;
12622
+ return [
12623
+ `### ${s.stepId} — ${oneLineSummary(s.title)} (${s.status})`,
12624
+ ...s.detail ? [`- result: ${oneLineSummary(s.detail)}`] : [],
12625
+ ...observations.length > 0 ? [`- observations: ${observations.map(oneLineSummary).join(" ; ")}`] : [],
12626
+ ...dropped > 0 ? [`- churn: ${churn.recorded} attempts ${churn.kept} kept (${dropped} dropped)`] : [],
12627
+ ...redundant > 0 ? [`- redundant: ${redundant} field(s) entered via 2+ selectors (kept both record one canonical selector)`] : [],
12628
+ ...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`] : [],
12629
+ ...cmds.length > 0 ? [`- kept commands: ${cmds.join(" ; ")}`] : []
12630
+ ].join("\n");
12631
+ }).join("\n\n")}`;
12632
+ }
12633
+ /**
12634
+ * Fold the ordered status lines into one entry per step: STEP_START opens
12635
+ * the entry (its detail is the step title); the terminal line sets the
12636
+ * outcome. A terminal line for a step that never emitted STEP_START still
12637
+ * gets an entry so its outcome isn't silently lost.
12638
+ */
12639
+ function collectStepSummaries(lines) {
12640
+ const byId = /* @__PURE__ */ new Map();
12641
+ const ordered = [];
12642
+ const ensure = (stepId, title) => {
12643
+ let entry = byId.get(stepId);
12644
+ if (!entry) {
12645
+ entry = {
12646
+ stepId,
12647
+ title,
12648
+ status: "NO_STATUS"
12649
+ };
12650
+ byId.set(stepId, entry);
12651
+ ordered.push(entry);
12652
+ }
12653
+ return entry;
12654
+ };
12655
+ for (const line of lines) {
12656
+ if (!line.stepId || line.type === "RUN_COMPLETED") continue;
12657
+ if (line.type === "STEP_START") {
12658
+ ensure(line.stepId, line.detail);
12659
+ continue;
12660
+ }
12661
+ const entry = ensure(line.stepId, "(untitled)");
12662
+ entry.status = line.type === "STEP_DONE" ? "DONE" : line.type === "ASSERTION_FAILED" ? "FAILED" : "SKIPPED";
12663
+ if (line.detail) entry.detail = line.detail;
12664
+ }
12665
+ return ordered;
12666
+ }
12667
+ /** Group each kept action's `action selector` form under its stepId. */
12668
+ function groupCommandsByStep(actions) {
12669
+ const byStep = /* @__PURE__ */ new Map();
12670
+ for (const a of actions) {
12671
+ if (!a.stepId) continue;
12672
+ const list = byStep.get(a.stepId) ?? [];
12673
+ list.push(formatRecordedAction(a));
12674
+ byStep.set(a.stepId, list);
12675
+ }
12676
+ return byStep;
12677
+ }
12678
+ /**
12679
+ * Group per-action observations (snapshot / assert prose) under their stepId
12680
+ * — the trace's own record of what it verified at each step.
12681
+ */
12682
+ function groupObservationsByStep(actions) {
12683
+ const byStep = /* @__PURE__ */ new Map();
12684
+ for (const a of actions) {
12685
+ if (!a.stepId || !a.observation) continue;
12686
+ const list = byStep.get(a.stepId) ?? [];
12687
+ list.push(a.observation);
12688
+ byStep.set(a.stepId, list);
12689
+ }
12690
+ return byStep;
12691
+ }
12692
+ /** Per-step count of kept-but-replay-unstable actions (the flaky selectors). */
12693
+ function countUnstableByStep(actions) {
12694
+ const byStep = /* @__PURE__ */ new Map();
12695
+ for (const a of actions) {
12696
+ if (!a.stepId || !a.replayUnstable) continue;
12697
+ byStep.set(a.stepId, (byStep.get(a.stepId) ?? 0) + 1);
12698
+ }
12699
+ return byStep;
12700
+ }
12701
+ /**
12702
+ * One kept action as `action selector` (with the fill value or assert
12703
+ * type when present) — the canonical form the record playbook should reuse.
12704
+ * Unlike the live summary this keeps selectors verbatim: record learns
12705
+ * concrete per-spec selectors, so masking them would defeat the purpose.
12706
+ *
12707
+ * A replay-unstable action (kept in lenient mode but flagged because its
12708
+ * selector wasn't present on a fresh replay) is tagged `[unstable](<reason>)`
12709
+ * so the learner does NOT record its selector as canonical — the reason is the
12710
+ * teacher signal ("not present within Nms" = timing-fragile, not stable).
12711
+ */
12712
+ function formatRecordedAction(a) {
12713
+ const parts = [a.action];
12714
+ if (a.index !== void 0) parts.push(String(a.index));
12715
+ const anchor = a.locator ? formatLocator(a.locator) : a.label;
12716
+ if (anchor) parts.push(anchor);
12717
+ if (a.value) parts.push(`= ${a.value}`);
12718
+ if (a.assert) parts.push(`(${a.assert})`);
12719
+ const line = oneLineSummary(parts.join(" "));
12720
+ return a.replayUnstable ? `${line} [unstable](${oneLineSummary(a.replayReason ?? "no reason")})` : line;
12721
+ }
12722
+ /** Verbatim locator form: raw selector for css, `by=value` for semantic ones. */
12723
+ function formatLocator(locator) {
12724
+ const name = locator.by === "role" && locator.name ? ` name=${locator.name}` : "";
12725
+ return locator.by === "css" ? locator.value : `${locator.by}=${locator.value}${name}`;
12726
+ }
12727
+ function oneLineSummary(s) {
12728
+ const flat = s.replace(/\s+/g, " ").trim();
12729
+ return flat.length > 240 ? flat.slice(0, 240) + "…" : flat || "(none)";
12730
+ }
12731
+ //#endregion
12732
+ //#region src/drift/format.ts
12733
+ /**
12734
+ * Render drift results as a string. The CLI commands and the `run` failure
12735
+ * hook are the only callers; both want the formatted output returned so
12736
+ * they can prefix / interleave / pipe it as needed.
12737
+ */
12738
+ function renderDrift(results, format, cwd) {
12739
+ if (format === "json") return renderJson(results);
12740
+ if (format === "github") return renderGithub(results, cwd);
12741
+ return renderText(results);
12742
+ }
12743
+ const HEAVY_RULE = "═".repeat(72);
12744
+ function renderText(results) {
12745
+ const out = [];
12746
+ for (const r of results) {
12747
+ out.push("");
12748
+ const heading = `══ ${r.target.featureName}/${r.target.specName} `;
12749
+ const tail = "═".repeat(Math.max(3, 72 - heading.length));
12750
+ out.push(`${heading}${tail}`);
12751
+ if (r.error) {
12752
+ out.push(` ERROR ${r.error}`);
12753
+ continue;
12754
+ }
12755
+ const errors = r.issues.filter((i) => i.severity === "ERROR");
12756
+ const warnings = r.issues.filter((i) => i.severity === "WARN");
12757
+ const passed = r.issues.filter((i) => i.severity === "OK");
12758
+ if (errors.length === 0 && warnings.length === 0) {
12759
+ const label = passed.length === 1 ? "check" : "checks";
12760
+ const detail = passed.length > 0 ? `all ${passed.length} ${label} passed` : "no issues";
12761
+ out.push(` ✓ ${detail}`);
12762
+ continue;
12763
+ }
12764
+ for (const issue of errors) appendFinding(out, "ERROR", issue);
12765
+ for (const issue of warnings) appendFinding(out, "WARN", issue);
12766
+ if (passed.length > 0) {
12767
+ const names = passed.map((i) => DRAFT_CATEGORY_LABEL[i.category]).join(", ");
12768
+ out.push("");
12769
+ out.push(` ✓ passed (${passed.length}): ${names}`);
12770
+ }
12771
+ }
12772
+ out.push("");
12773
+ out.push(HEAVY_RULE);
12774
+ const totals = summarize(results);
12775
+ out.push(` specs ${results.length} (${totals.errored} errored)`);
12776
+ out.push(` findings ${totals.error} error, ${totals.warn} warn, ${totals.ok} ok`);
12777
+ out.push("");
12778
+ return out.join("\n");
12779
+ }
12780
+ function appendFinding(out, level, issue) {
12781
+ const stepPart = issue.stepId ? ` ${issue.stepId}` : "";
12782
+ out.push("");
12783
+ out.push(` ${level} ${DRAFT_CATEGORY_LABEL[issue.category]}${stepPart}`);
12784
+ out.push(` ${issue.message}`);
12785
+ if (issue.detail) out.push(` └ ${issue.detail.replace(/\n/g, "\n ")}`);
12786
+ }
12787
+ function renderJson(results) {
12788
+ const payload = { specs: results.map((r) => ({
12789
+ feature: r.target.featureName,
12790
+ spec: r.target.specName,
12791
+ ok: r.ok,
12792
+ ...r.error ? { error: r.error } : {},
12793
+ issues: r.issues.map((i) => ({
12794
+ severity: i.severity,
12795
+ category: i.category,
12796
+ stepId: i.stepId,
12797
+ message: i.message,
12798
+ ...i.detail ? { detail: i.detail } : {}
12799
+ }))
12800
+ })) };
12801
+ return `${JSON.stringify(payload, null, 2)}\n`;
12802
+ }
12803
+ function renderGithub(results, cwd) {
12804
+ const repoRoot = process.env["GITHUB_WORKSPACE"] ?? process.cwd();
12805
+ const lines = [];
12806
+ for (const r of results) {
12807
+ const file = githubRelPath(cwd, repoRoot, r.target.featureName, r.target.specName);
12808
+ if (r.error) {
12809
+ lines.push(`::error file=${file}::${escapeGhMessage(r.error)}`);
12810
+ continue;
12811
+ }
12812
+ for (const issue of r.issues) {
12813
+ if (issue.severity === "OK") continue;
12814
+ const level = issue.severity === "ERROR" ? "error" : "warning";
12815
+ const title = `${r.target.featureName}/${r.target.specName} — ${issue.category}${issue.stepId ? ` (${issue.stepId})` : ""}`;
12816
+ const body = issue.detail ? `${issue.message}\n${issue.detail}` : issue.message;
12817
+ lines.push(`::${level} file=${file},title=${escapeGhProp(title)}::${escapeGhMessage(body)}`);
12818
+ }
12819
+ }
12820
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
12806
12821
  }
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
- `;
12822
+ function githubRelPath(cwd, repoRoot, featureName, specName) {
12823
+ const abs = resolve(cwd, ".ccqa", "features", featureName, "test-cases", specName, "spec.yaml");
12824
+ const rel = relative(repoRoot, abs);
12825
+ return rel.startsWith("..") ? abs : rel;
12811
12826
  }
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
- `;
12827
+ function escapeGhMessage(s) {
12828
+ return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
12826
12829
  }
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;
12830
+ function escapeGhProp(s) {
12831
+ return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A").replace(/,/g, "%2C").replace(/:/g, "%3A");
12832
+ }
12833
+ function summarize(results) {
12834
+ let error = 0;
12835
+ let warn = 0;
12836
+ let ok = 0;
12837
+ let errored = 0;
12838
+ for (const r of results) {
12839
+ if (r.error) errored++;
12840
+ for (const issue of r.issues) if (issue.severity === "ERROR") error++;
12841
+ else if (issue.severity === "WARN") warn++;
12842
+ else ok++;
12869
12843
  }
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)));
12844
+ return {
12845
+ error,
12846
+ warn,
12847
+ ok,
12848
+ errored
12849
+ };
12874
12850
  }
12851
+ //#endregion
12852
+ //#region src/drift/exit-code.ts
12875
12853
  /**
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.
12854
+ * Map drift results to an exit code. Spec-level errors (Claude call failed)
12855
+ * always fail; otherwise ERROR severity always fails, WARN fails only when
12856
+ * the threshold is `warn`.
12880
12857
  */
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));
12858
+ function determineExitCode(results, threshold) {
12859
+ for (const r of results) {
12860
+ if (r.error) return 1;
12861
+ for (const issue of r.issues) {
12862
+ if (issue.severity === "ERROR") return 1;
12863
+ if (threshold === "warn" && issue.severity === "WARN") return 1;
12864
+ }
12865
+ }
12866
+ return 0;
12903
12867
  }
12904
12868
  /**
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.
12869
+ * Spec-level status under the given threshold, mirroring determineExitCode's
12870
+ * per-issue logic (exit-code.ts) but scoped to a single SpecResult.
12910
12871
  */
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;
12872
+ function specStatus(result, threshold) {
12873
+ if (result.error) return "failed";
12874
+ for (const issue of result.issues) {
12875
+ if (issue.severity === "ERROR") return "failed";
12876
+ if (threshold === "warn" && issue.severity === "WARN") return "failed";
12919
12877
  }
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;
12878
+ return "passed";
12924
12879
  }
12925
12880
  /**
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.
12881
+ * Adapts `ccqa drift` results into the shared RunReportData shape so they can
12882
+ * be pushed to the hub (`ccqa drift --push`) and rendered by the same report
12883
+ * UI as `ccqa run`/`ccqa live`. Browser-execution fields (testCounts,
12884
+ * evidence, liveRun, ...) don't apply to a drift audit and are always null.
12929
12885
  */
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
- })
12886
+ function driftResultsToReport(results, meta) {
12887
+ const specResults = results.map((result) => ({
12888
+ feature: result.target.featureName,
12889
+ spec: result.target.specName,
12890
+ title: null,
12891
+ status: specStatus(result, meta.threshold),
12892
+ testCounts: null,
12893
+ durationMs: null,
12894
+ assertions: null,
12895
+ analysis: null,
12896
+ analysisSkipped: null,
12897
+ driftIssues: result.issues,
12898
+ failureLogExcerpt: null,
12899
+ diffExcerpt: null,
12900
+ specYaml: null,
12901
+ evidence: null,
12902
+ liveRun: null
12949
12903
  }));
12950
12904
  return {
12951
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
12952
- features
12905
+ schemaVersion: 1,
12906
+ kind: "drift",
12907
+ createdAt: meta.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
12908
+ runId: meta.runId ?? null,
12909
+ git: meta.git,
12910
+ model: meta.model ?? null,
12911
+ language: meta.language ?? null,
12912
+ promptVersion: meta.promptVersion ?? "1",
12913
+ customPromptVersion: null,
12914
+ results: specResults
12953
12915
  };
12954
12916
  }
12917
+ //#endregion
12918
+ //#region src/drift/route-new-files.ts
12955
12919
  /**
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.
12920
+ * Lightweight Claude call: given a list of new files in the PR and the existing
12921
+ * specs (with their relatedPaths globs as a hint), return the spec keys (in
12922
+ * "<feature>/<spec>" form) that the new files plausibly affect.
12923
+ *
12924
+ * Conservative by design — false positives are safer than false negatives,
12925
+ * because a missed spec turns into undetected drift in CI. When the router
12926
+ * call itself fails, we log a warning rather than fail-close: the surrounding
12927
+ * glob match is the primary signal; the router only adds coverage for new
12928
+ * paths no glob captures.
12959
12929
  */
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();
12930
+ async function routeNewFilesToSpecs(input) {
12931
+ const { newFiles, specs, cwd, model } = input;
12932
+ const empty = /* @__PURE__ */ new Set();
12933
+ if (newFiles.length === 0 || specs.length === 0) return empty;
13008
12934
  const { result, isError } = await invokeClaudeStreaming({
13009
- prompt: buildPerspectivesPrompt(specs, opts.instruction),
13010
- systemPrompt: buildPerspectivesSystemPrompt() + languageDirective(opts.language),
12935
+ prompt: buildRouterPrompt(await Promise.all(newFiles.map(async (path) => ({
12936
+ path,
12937
+ head: await readHead(join(cwd, path))
12938
+ }))), specs),
12939
+ systemPrompt: buildRouterSystemPrompt(),
13011
12940
  allowedTools: [
13012
12941
  "Read",
13013
12942
  "Grep",
13014
12943
  "Glob"
13015
12944
  ],
13016
12945
  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`);
12946
+ cwd,
12947
+ ...model ? { model } : {}
12948
+ }, (_msg) => {});
13023
12949
  if (isError) {
13024
- error("Claude returned an error result");
13025
- return null;
12950
+ warn("new-file router: Claude returned an error; skipping router signal");
12951
+ return empty;
13026
12952
  }
13027
12953
  const json = extractJsonBlock(result);
13028
12954
  if (!json) {
13029
- error("Claude did not return a json block");
13030
- return null;
12955
+ warn("new-file router: no JSON block in response; skipping router signal");
12956
+ return empty;
13031
12957
  }
13032
- return parseSummaries(json);
12958
+ let parsed;
12959
+ try {
12960
+ parsed = JSON.parse(json);
12961
+ } catch (e) {
12962
+ warn(`new-file router: failed to parse JSON (${e.message}); skipping router signal`);
12963
+ return empty;
12964
+ }
12965
+ const out = /* @__PURE__ */ new Set();
12966
+ const validKeys = new Set(specs.map((s) => `${s.featureName}/${s.specName}`));
12967
+ if (typeof parsed === "object" && parsed !== null && "affectedSpecs" in parsed) {
12968
+ const arr = parsed.affectedSpecs;
12969
+ if (Array.isArray(arr)) {
12970
+ for (const item of arr) if (typeof item === "string" && validKeys.has(item)) out.add(item);
12971
+ }
12972
+ }
12973
+ return out;
12974
+ }
12975
+ async function readHead(absPath) {
12976
+ const content = await readFile(absPath, "utf-8").catch(() => "");
12977
+ if (!content) return "";
12978
+ return content.split("\n").slice(0, 40).join("\n");
12979
+ }
12980
+ function buildRouterSystemPrompt() {
12981
+ return `You triage which ccqa test specs are potentially affected by NEW source files added in a pull request.
12982
+
12983
+ You will receive:
12984
+ - A list of new files (path + first ~40 lines of each)
12985
+ - A list of existing specs with their declared relatedPaths globs
12986
+
12987
+ Your job: return the spec keys (in "<feature>/<spec>" form) whose behaviour might depend on any of the new files.
12988
+
12989
+ ## Rules
12990
+
12991
+ - 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.
12992
+ - 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.
12993
+ - 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/**\`).
12994
+ - Files like tests, generated code, build artifacts, vendor dirs typically do not affect any spec. Skip them.
12995
+
12996
+ ## Output (STRICT)
12997
+
12998
+ Output ONE fenced \`\`\`json block, nothing else:
12999
+
13000
+ \`\`\`json
13001
+ {
13002
+ "affectedSpecs": ["feature/spec", "feature/spec"]
13003
+ }
13004
+ \`\`\`
13005
+
13006
+ Use exactly the keys you saw in the input ("<feature>/<spec>"). Return an empty array if no spec is affected.
13007
+ `;
13008
+ }
13009
+ function buildRouterPrompt(previews, specs) {
13010
+ return `## New files
13011
+
13012
+ ${previews.map((p) => {
13013
+ const headBlock = p.head ? `\n\`\`\`\n${p.head}\n\`\`\`` : "\n(empty or unreadable)";
13014
+ return `### ${p.path}${headBlock}`;
13015
+ }).join("\n\n")}
13016
+
13017
+ ## Existing specs
13018
+
13019
+ ${specs.map((s) => {
13020
+ const paths = s.relatedPaths.length === 0 ? " (no relatedPaths declared)" : s.relatedPaths.map((p) => ` - ${p}`).join("\n");
13021
+ return `- ${s.featureName}/${s.specName}\n${paths}`;
13022
+ }).join("\n")}
13023
+
13024
+ ## Task
13025
+
13026
+ Return the spec keys that might be affected by any of the new files. Conservative inclusion is preferred over missing real drift.
13027
+ `;
13033
13028
  }
13029
+ //#endregion
13030
+ //#region src/cli/drift.ts
13031
+ const DEFAULT_CONCURRENCY = 3;
13032
+ 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) => {
13033
+ const format = parseFormat(opts.format);
13034
+ const threshold = parseSeverity(opts.severity);
13035
+ const concurrency = parseConcurrency(opts.concurrency);
13036
+ const cwd = resolveCwd(opts.cwd);
13037
+ await ensureCcqaDir(cwd);
13038
+ if (opts.changed && specPath) {
13039
+ error("--changed and an explicit spec id cannot be combined; --changed only applies to a full sweep");
13040
+ process.exit(2);
13041
+ }
13042
+ let targets = await collectTargets(specPath, cwd);
13043
+ if (targets.length === 0) exitWithNoSpecs(format, "no test specs found under .ccqa/features/");
13044
+ if (format === "text") {
13045
+ header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
13046
+ if (opts.cwd) meta("cwd", cwd);
13047
+ }
13048
+ const baseRef = opts.changed ? resolveBaseRef(opts.base) : null;
13049
+ if (opts.changed) {
13050
+ const total = targets.length;
13051
+ targets = await filterByChanged({
13052
+ targets,
13053
+ cwd,
13054
+ baseOverride: opts.base,
13055
+ format,
13056
+ model: opts.model
13057
+ });
13058
+ if (format === "text") meta("scoped", `${targets.length} of ${total} spec${total > 1 ? "s" : ""}`);
13059
+ if (targets.length === 0) exitWithNoSpecs(format, "no specs intersect the changed file set; nothing to check");
13060
+ }
13061
+ const blocks = await loadAvailableBlocks(cwd);
13062
+ const results = await analyzeDrift({
13063
+ targets,
13064
+ cwd,
13065
+ blocks,
13066
+ concurrency,
13067
+ ...opts.model ? { model: opts.model } : {},
13068
+ ...opts.language ? { language: opts.language } : {},
13069
+ onSpecStart: (t) => {
13070
+ if (format === "text") info(`checking ${t.featureName}/${t.specName}`);
13071
+ }
13072
+ });
13073
+ process.stdout.write(renderDrift(results, format, cwd));
13074
+ if (opts.push) await pushDriftResults({
13075
+ results,
13076
+ threshold,
13077
+ cwd,
13078
+ opts,
13079
+ format,
13080
+ baseRef
13081
+ });
13082
+ process.exit(determineExitCode(results, threshold));
13083
+ });
13034
13084
  /**
13035
- * Parse the `{ summaries: [...] }` JSON contract into typed entries. Returns
13036
- * null and logs when the payload is malformed. Exported for unit testing.
13085
+ * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
13086
+ * shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
13087
+ * hub connection warns and returns rather than failing the command (`--push`
13088
+ * never changes drift's own exit code).
13089
+ *
13090
+ * `resolveHub` is injectable so tests can supply a fake `HubClient` without
13091
+ * a real hub connection; it defaults to the real flag/env resolution.
13037
13092
  */
13038
- function parseSummaries(json) {
13039
- let payload;
13093
+ async function pushDriftResults(args, resolveHub = resolveHubClient) {
13094
+ const { results, threshold, cwd, opts, format, baseRef } = args;
13095
+ const hub = resolveHub(opts);
13096
+ if (!hub) {
13097
+ warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
13098
+ return;
13099
+ }
13040
13100
  try {
13041
- payload = JSON.parse(json);
13101
+ const project = resolveProject({
13102
+ project: opts.project,
13103
+ cwd
13104
+ });
13105
+ const [branch, head] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
13106
+ const report = driftResultsToReport(results, {
13107
+ threshold,
13108
+ git: {
13109
+ head,
13110
+ base: baseRef ?? null
13111
+ }
13112
+ });
13113
+ const dir = await mkdtemp(join(tmpdir(), "ccqa-drift-push-"));
13114
+ try {
13115
+ await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2), "utf8");
13116
+ const archive = await packDirToTarGz(dir);
13117
+ const run = await hub.pushRun(archive, {
13118
+ project,
13119
+ ...branch ? { branch } : {},
13120
+ kind: "drift"
13121
+ });
13122
+ if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${run.id}`);
13123
+ } finally {
13124
+ await rm(dir, {
13125
+ recursive: true,
13126
+ force: true
13127
+ });
13128
+ }
13129
+ } catch (err) {
13130
+ if (err instanceof HubApiError) {
13131
+ error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
13132
+ process.exit(2);
13133
+ }
13134
+ throw err;
13135
+ }
13136
+ }
13137
+ function exitWithNoSpecs(format, message) {
13138
+ if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
13139
+ else if (format === "text") info(message);
13140
+ process.exit(0);
13141
+ }
13142
+ async function filterByChanged(input) {
13143
+ const { targets, cwd, baseOverride, format, model } = input;
13144
+ const base = resolveBaseRef(baseOverride);
13145
+ let changed;
13146
+ try {
13147
+ changed = await getChangedFiles(base, cwd);
13042
13148
  } catch (e) {
13043
- error(`failed to parse summaries JSON: ${e.message}`);
13044
- return null;
13149
+ error(`failed to run 'git diff' against ${base}: ${e.message}`);
13150
+ process.exit(2);
13045
13151
  }
13046
- if (typeof payload !== "object" || payload === null) {
13047
- error("summaries payload is not an object");
13048
- return null;
13152
+ if (format === "text") {
13153
+ meta("changed-base", base);
13154
+ meta("changed-files", changed.length);
13155
+ }
13156
+ if (changed.length === 0) return [];
13157
+ const newFiles = changed.filter((f) => f.status === "added" && !f.outsideCwd);
13158
+ const existingChanges = changed.filter((f) => f.status !== "added" || f.outsideCwd);
13159
+ const affected = /* @__PURE__ */ new Set();
13160
+ const touchedBlockNames = /* @__PURE__ */ new Set();
13161
+ for (const f of changed) {
13162
+ if (f.outsideCwd) continue;
13163
+ const blockName = parseBlockPath(f.path);
13164
+ if (blockName) touchedBlockNames.add(blockName);
13165
+ }
13166
+ for (const t of targets) {
13167
+ if (!t.relatedPaths) {
13168
+ affected.add(specKey(t));
13169
+ continue;
13170
+ }
13171
+ if (existingChanges.some((f) => isPathAffectedBy(f.path, t.relatedPaths)) || newFiles.some((f) => isPathAffectedBy(f.path, t.relatedPaths))) {
13172
+ affected.add(specKey(t));
13173
+ continue;
13174
+ }
13175
+ if (t.includedBlocks?.some((name) => touchedBlockNames.has(name))) affected.add(specKey(t));
13176
+ }
13177
+ if (newFiles.length > 0) {
13178
+ if (format === "text") info(`routing ${newFiles.length} new file(s) to specs via Claude...`);
13179
+ const routed = await routeNewFilesToSpecs({
13180
+ newFiles: newFiles.map((f) => f.path),
13181
+ specs: targets.filter((t) => t.relatedPaths).map((t) => ({
13182
+ featureName: t.featureName,
13183
+ specName: t.specName,
13184
+ relatedPaths: t.relatedPaths
13185
+ })),
13186
+ cwd,
13187
+ model
13188
+ });
13189
+ for (const key of routed) affected.add(key);
13049
13190
  }
13050
- const summaries = payload.summaries;
13051
- if (!Array.isArray(summaries)) {
13052
- error("summaries payload missing a `summaries` array");
13053
- return null;
13191
+ return targets.filter((t) => affected.has(specKey(t)));
13192
+ }
13193
+ async function collectTargets(specPath, cwd) {
13194
+ const tree = await listFeatureTree(cwd);
13195
+ if (specPath) {
13196
+ const { featureName, specName } = parseSpecPath(specPath);
13197
+ const spec = tree.find((f) => f.featureName === featureName)?.specs.find((s) => s.specName === specName);
13198
+ if (!spec?.hasSpecFile) {
13199
+ error(`spec not found: ${featureName}/${specName} (under ${cwd})`);
13200
+ process.exit(1);
13201
+ }
13202
+ return [{
13203
+ featureName,
13204
+ specName,
13205
+ includedBlocks: spec.includedBlocks ?? []
13206
+ }];
13054
13207
  }
13055
13208
  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
- }
13209
+ for (const feature of tree) for (const spec of feature.specs) {
13210
+ if (!spec.hasSpecFile) continue;
13211
+ const t = {
13212
+ featureName: feature.featureName,
13213
+ specName: spec.specName
13214
+ };
13215
+ if (spec.relatedPaths) t.relatedPaths = spec.relatedPaths;
13216
+ if (spec.includedBlocks) t.includedBlocks = spec.includedBlocks;
13217
+ out.push(t);
13073
13218
  }
13074
13219
  return out;
13075
13220
  }
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`;
13221
+ function parseFormat(raw) {
13222
+ const v = raw ?? "text";
13223
+ if (v === "text" || v === "json" || v === "github") return v;
13224
+ error(`invalid --format: ${v} (expected text|json|github)`);
13225
+ process.exit(2);
13147
13226
  }
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`;
13227
+ function parseSeverity(raw) {
13228
+ const v = raw ?? "error";
13229
+ if (v === "warn" || v === "error") return v;
13230
+ error(`invalid --severity: ${v} (expected warn|error)`);
13231
+ process.exit(2);
13156
13232
  }
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("");
13233
+ function parseConcurrency(raw) {
13234
+ if (raw === void 0) return DEFAULT_CONCURRENCY;
13235
+ const n = Number.parseInt(raw, 10);
13236
+ if (!Number.isFinite(n) || n < 1) {
13237
+ error(`invalid --concurrency: ${raw} (expected positive integer)`);
13238
+ process.exit(2);
13184
13239
  }
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, " ");
13240
+ return n;
13236
13241
  }
13237
13242
  //#endregion
13243
+ //#region src/cli/init.ts
13244
+ 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) => {
13245
+ const cwd = resolveCwd(opts.cwd);
13246
+ header("init", cwd);
13247
+ await ensureCcqaDir(cwd);
13248
+ info("created .ccqa/features/, .ccqa/blocks/");
13249
+ blank();
13250
+ hint("set CCQA_HUB_URL / CCQA_HUB_TOKEN to connect to a ccqa hub");
13251
+ hint("edit guidance prompts in the hub UI's Prompts tab");
13252
+ hint("run `ccqa record <feature>/<spec>` to start recording");
13253
+ });
13254
+ //#endregion
13238
13255
  //#region src/cli/session.ts
13239
13256
  const AB = createRequire(import.meta.url).resolve("agent-browser/bin/agent-browser.js");
13240
13257
  /**
@@ -14107,7 +14124,7 @@ function createListProfilesHandler(storage) {
14107
14124
  //#region src/hub/api/handlers/prompts.ts
14108
14125
  const MAX_PROMPT_BODY_BYTES = 256 * 1024;
14109
14126
  /** Validate the `:project` route param (prompts are project-scoped, not per-profile). */
14110
- function requireProject(ctx) {
14127
+ function requireProject$1(ctx) {
14111
14128
  return requireSafeSegment(ctx.params.project, "project");
14112
14129
  }
14113
14130
  /**
@@ -14143,7 +14160,7 @@ function metaFor(name, body) {
14143
14160
  /** PUT /api/v1/projects/:project/prompts/:name — body is Markdown (guidance) or custom prompt JSON. */
14144
14161
  function createPutPromptHandler(config) {
14145
14162
  return async (ctx) => {
14146
- const project = requireProject(ctx);
14163
+ const project = requireProject$1(ctx);
14147
14164
  const name = requirePromptName(ctx);
14148
14165
  const body = await readBody(ctx.req, MAX_PROMPT_BODY_BYTES);
14149
14166
  await config.store.put(project, name, new Uint8Array(body), metaFor(name, body));
@@ -14154,7 +14171,7 @@ function createPutPromptHandler(config) {
14154
14171
  /** GET /api/v1/projects/:project/prompts — names + kinds + timestamps (no bodies). */
14155
14172
  function createListPromptsHandler(config) {
14156
14173
  return async (ctx) => {
14157
- const project = requireProject(ctx);
14174
+ const project = requireProject$1(ctx);
14158
14175
  const entries = await config.store.list(project);
14159
14176
  sendJson(ctx.res, 200, { prompts: entries.map((e) => ({
14160
14177
  name: e.name,
@@ -14167,7 +14184,7 @@ function createListPromptsHandler(config) {
14167
14184
  /** GET /api/v1/projects/:project/prompts/:name — the raw prompt body, or 404. */
14168
14185
  function createGetPromptHandler(config) {
14169
14186
  return async (ctx) => {
14170
- const project = requireProject(ctx);
14187
+ const project = requireProject$1(ctx);
14171
14188
  const name = requirePromptName(ctx);
14172
14189
  const stored = await config.store.get(project, name);
14173
14190
  if (!stored) throw new HttpError(404, "not_found", `prompt "${name}" not found for project "${project}"`);
@@ -14178,7 +14195,7 @@ function createGetPromptHandler(config) {
14178
14195
  /** DELETE /api/v1/projects/:project/prompts/:name */
14179
14196
  function createDeletePromptHandler(config) {
14180
14197
  return async (ctx) => {
14181
- const project = requireProject(ctx);
14198
+ const project = requireProject$1(ctx);
14182
14199
  const name = requirePromptName(ctx);
14183
14200
  await config.store.delete(project, name);
14184
14201
  ctx.res.statusCode = 204;
@@ -14186,6 +14203,93 @@ function createDeletePromptHandler(config) {
14186
14203
  };
14187
14204
  }
14188
14205
  //#endregion
14206
+ //#region src/hub/api/handlers/perspectives.ts
14207
+ const MAX_PERSPECTIVES_BODY_BYTES = 1024 * 1024;
14208
+ /** Validate the `:project` route param (perspectives are project-scoped, one document per project). */
14209
+ function requireProject(ctx) {
14210
+ return requireSafeSegment(ctx.params.project, "project");
14211
+ }
14212
+ /**
14213
+ * PUT /api/v1/projects/:project/perspectives — body is the perspectives
14214
+ * document as JSON. Schema validation is the CLI's job (it Zod-validates
14215
+ * before pushing); the hub only rejects bodies that aren't a JSON object,
14216
+ * so the UI can always JSON.parse what it reads back.
14217
+ */
14218
+ function createPutPerspectivesHandler(config) {
14219
+ return async (ctx) => {
14220
+ const project = requireProject(ctx);
14221
+ const body = await readBody(ctx.req, MAX_PERSPECTIVES_BODY_BYTES);
14222
+ let parsed;
14223
+ try {
14224
+ parsed = JSON.parse(body.toString("utf8"));
14225
+ } catch {
14226
+ throw new HttpError(400, "invalid_body", "perspectives body must be valid JSON");
14227
+ }
14228
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new HttpError(400, "invalid_body", "perspectives body must be a JSON object");
14229
+ await config.store.put(project, new Uint8Array(body));
14230
+ ctx.res.statusCode = 204;
14231
+ ctx.res.end();
14232
+ };
14233
+ }
14234
+ /** GET /api/v1/projects/:project/perspectives — the stored document, or 404. */
14235
+ function createGetPerspectivesHandler(config) {
14236
+ return async (ctx) => {
14237
+ const project = requireProject(ctx);
14238
+ const stored = await config.store.get(project);
14239
+ if (!stored) throw new HttpError(404, "not_found", `no perspectives stored for project "${project}"`);
14240
+ sendBytes(ctx.res, 200, stored, "application/json; charset=utf-8");
14241
+ };
14242
+ }
14243
+ /** DELETE /api/v1/projects/:project/perspectives */
14244
+ function createDeletePerspectivesHandler(config) {
14245
+ return async (ctx) => {
14246
+ const project = requireProject(ctx);
14247
+ await config.store.delete(project);
14248
+ ctx.res.statusCode = 204;
14249
+ ctx.res.end();
14250
+ };
14251
+ }
14252
+ const MAX_NOTE_BODY_BYTES = 64 * 1024;
14253
+ function parseNotePatch(body) {
14254
+ let parsed;
14255
+ try {
14256
+ parsed = JSON.parse(body.toString("utf8"));
14257
+ } catch {
14258
+ throw new HttpError(400, "invalid_body", "note patch must be valid JSON");
14259
+ }
14260
+ const rec = parsed;
14261
+ 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");
14262
+ return {
14263
+ feature: rec.feature,
14264
+ spec: rec.spec,
14265
+ note: rec.note
14266
+ };
14267
+ }
14268
+ /**
14269
+ * PATCH /api/v1/projects/:project/perspectives — the hub UI's note editing.
14270
+ * Body: `{ feature, spec, note }`; an empty `note` clears the field. The
14271
+ * note is the only human-authored field in the document, and this is its
14272
+ * only write path now that the document never lives in the consuming repo.
14273
+ * Runs as a serialized read-modify-write so an edit can't clobber (or be
14274
+ * clobbered by) a concurrent one.
14275
+ */
14276
+ function createPatchPerspectivesNoteHandler(config) {
14277
+ return async (ctx) => {
14278
+ const project = requireProject(ctx);
14279
+ const patch = parseNotePatch(await readBody(ctx.req, MAX_NOTE_BODY_BYTES));
14280
+ await config.store.update(project, (current) => {
14281
+ if (current === null) throw new HttpError(404, "not_found", `no perspectives stored for project "${project}"`);
14282
+ const specEntry = current.features?.find((f) => f?.featureName === patch.feature)?.specs?.find((s) => s?.specName === patch.spec);
14283
+ if (!specEntry) throw new HttpError(404, "not_found", `no spec "${patch.feature}/${patch.spec}" in the perspectives document`);
14284
+ if (patch.note === "") delete specEntry.note;
14285
+ else specEntry.note = patch.note;
14286
+ return current;
14287
+ });
14288
+ ctx.res.statusCode = 204;
14289
+ ctx.res.end();
14290
+ };
14291
+ }
14292
+ //#endregion
14189
14293
  //#region src/hub/api/handlers/learning-jobs.ts
14190
14294
  const MAX_BODY_BYTES = 4 * 1024;
14191
14295
  /** POST /api/v1/projects/:project/learning-jobs — create + enqueue a triage-learning job. */
@@ -14569,6 +14673,7 @@ const HTML_BODY = `
14569
14673
  <div class="nav-group" id="sidebar-project">no project</div>
14570
14674
  <nav class="nav">
14571
14675
  <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>
14676
+ <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
14677
  <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
14678
  <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
14679
  <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 +14722,34 @@ const HTML_BODY = `
14617
14722
  </div>
14618
14723
  </section>
14619
14724
 
14725
+ <!-- ===== PERSPECTIVES ===== -->
14726
+ <section id="view-perspectives" hidden>
14727
+ <div class="page-bar">
14728
+ <h1 data-i18n="perspectives.title">Perspectives</h1>
14729
+ <span class="updated" id="persp-updated"></span>
14730
+ <div class="spacer"></div>
14731
+ <button class="btn ghost sm" id="persp-refresh" data-i18n="common.refresh">Refresh</button>
14732
+ </div>
14733
+ <div class="content">
14734
+ <p id="persp-status" class="empty-note" hidden></p>
14735
+ <div id="persp-body" hidden>
14736
+ <div class="ov" id="persp-ov"></div>
14737
+ <div class="toolbar">
14738
+ <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>
14739
+ <button class="fchip" data-f="all" aria-pressed="true" type="button" data-i18n="perspectives.filter.all">All</button>
14740
+ <button class="fchip" data-f="deterministic" aria-pressed="false" type="button" data-i18n="perspectives.filter.deterministic">Deterministic</button>
14741
+ <button class="fchip" data-f="live" aria-pressed="false" type="button" data-i18n="perspectives.filter.live">Live</button>
14742
+ <button class="fchip" data-f="norec" aria-pressed="false" type="button" data-i18n="perspectives.filter.norec">Not recorded only</button>
14743
+ </div>
14744
+ <div class="tblcard"><div class="table-wrap"><table>
14745
+ <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>
14746
+ <tbody id="persp-tbody"></tbody>
14747
+ </table></div></div>
14748
+ <p class="empty-note" id="persp-no-hit" hidden data-i18n="perspectives.noHit">No matching cases.</p>
14749
+ </div>
14750
+ </div>
14751
+ </section>
14752
+
14620
14753
  <!-- ===== RUN DETAIL ===== -->
14621
14754
  <section id="view-detail" hidden>
14622
14755
  <div class="page-bar">
@@ -15240,6 +15373,65 @@ const CSS = `
15240
15373
  .prompt-diff pre { margin: 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2);
15241
15374
  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
15375
 
15376
+ /* perspectives — coverage strip, filter toolbar, and the one-table-per-project
15377
+ view (feature section rows + expandable case detail rows). Reuses the
15378
+ existing badge/chip primitives above; --pass/--amber cover the "runnable"
15379
+ vs "not recorded" coloring so no new tokens are needed. */
15380
+ .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; }
15381
+ .ov .num { display: flex; flex-direction: column; }
15382
+ .ov .num b { font-size: 24px; line-height: 1.15; font-weight: 650; font-variant-numeric: tabular-nums; }
15383
+ .ov .num span { font-size: 12px; color: var(--muted); }
15384
+ .ov .sep { width: 1px; align-self: stretch; background: var(--border); }
15385
+ .ov .breakdown { display: flex; gap: 20px; }
15386
+ .covwrap { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 6px; }
15387
+ .covbar { height: 8px; border-radius: 999px; overflow: hidden; display: flex; background: var(--surface-3); }
15388
+ .covbar .ok { background: var(--pass); }
15389
+ .covbar .no { background: var(--amber); opacity: 0.75; }
15390
+ .covleg { display: flex; gap: 16px; font-size: 12px; color: var(--muted); }
15391
+ .covleg i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
15392
+ .covleg .lg-ok i { background: var(--pass); }
15393
+ .covleg .lg-no i { background: var(--amber); opacity: 0.75; }
15394
+
15395
+ .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); }
15396
+ .search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
15397
+ .search input { border: none; outline: none; font: inherit; font-size: 13px; width: 100%; background: transparent; color: var(--fg); }
15398
+ .fchip { border: 1px solid var(--border-strong); background: var(--surface); border-radius: 999px; padding: 5px 12px; font-size: 12.5px; color: var(--muted); }
15399
+ .fchip[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
15400
+
15401
+ .chip.live { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
15402
+ .badge.ok { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
15403
+ .badge.ok .d { background: var(--pass); }
15404
+ .badge.norec { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
15405
+ .badge.norec .d { background: var(--amber); }
15406
+
15407
+ .tblcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
15408
+ /* Feature section rows must read as headings, not as just another data row —
15409
+ larger, darker, extra padding, and a strong top rule marking the break. */
15410
+ 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); }
15411
+ tr.grp td .gcount { color: var(--muted); font-weight: 500; font-size: 12px; font-family: var(--font); margin-left: 10px; }
15412
+ td.c-title { font-weight: 500; max-width: 460px; }
15413
+ td.c-title .csum { display: block; font-weight: 400; color: var(--muted); font-size: 12.5px; margin-top: 1px; }
15414
+ td.c-chev { width: 28px; color: var(--muted-2); text-align: right; }
15415
+ .chev-i { display: inline-block; transition: transform 0.15s; font-size: 11px; }
15416
+ tr.row[aria-expanded="true"] .chev-i { transform: rotate(90deg); }
15417
+ tr.detail { display: none; }
15418
+ tr.detail.open { display: table-row; }
15419
+ tr.detail > td { background: var(--surface-2); padding: 14px 16px 16px; }
15420
+
15421
+ .d-grid { display: grid; grid-template-columns: 120px 1fr; gap: 7px 14px; font-size: 13px; max-width: 860px; }
15422
+ .d-grid dt { color: var(--muted); font-size: 12px; padding-top: 1px; }
15423
+ .d-grid dd { color: var(--fg-dim); }
15424
+ .d-grid dd ul { list-style: none; display: flex; flex-direction: column; gap: 3px; margin: 0; padding: 0; }
15425
+ .d-grid dd li::before { content: "\\2022 "; color: var(--muted-2); }
15426
+ .d-grid code { font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
15427
+ .notebox { margin-top: 12px; max-width: 860px; }
15428
+ .notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
15429
+ .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; }
15430
+ .notebox .nact { margin-top: 6px; display: flex; align-items: center; gap: 8px; }
15431
+ .notebox .nstatus { font-size: 12px; color: var(--muted); }
15432
+ .notebox .nstatus.ok { color: var(--pass); }
15433
+ .notebox .nstatus.err { color: var(--fail); }
15434
+
15243
15435
  @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
15436
  @media (max-width: 700px) { .prompt-grid { grid-template-columns: 1fr; } .prompt-diff { grid-template-columns: 1fr; } }
15245
15437
  `;
@@ -15266,7 +15458,7 @@ const CLIENT_JS = `
15266
15458
  // only their display text is localized via FAILURE_LABEL_JA.
15267
15459
  var I18N = {
15268
15460
  en: {
15269
- "nav.projects": "Projects", "nav.runs": "Runs", "nav.secrets": "Secrets",
15461
+ "nav.projects": "Projects", "nav.runs": "Runs", "nav.perspectives": "Perspectives", "nav.secrets": "Secrets",
15270
15462
  "nav.prompts": "Prompts", "nav.learning": "Learning",
15271
15463
  "app.project": "project", "app.profile": "profile", "app.disconnect": "Disconnect", "app.noProject": "no project",
15272
15464
  "app.newProfile": "New profile",
@@ -15308,6 +15500,27 @@ const CLIENT_JS = `
15308
15500
  "learn.cta.desc": "Learn from what you graded so ccqa classifies failure causes the same way next time.",
15309
15501
  "learn.cta.run": "Learn",
15310
15502
  "secrets.title": "Secrets", "prompts.title": "Prompts", "learning.title": "Learning",
15503
+ "perspectives.title": "Perspectives",
15504
+ "perspectives.search": "Search cases…",
15505
+ "perspectives.filter.all": "All", "perspectives.filter.deterministic": "Deterministic",
15506
+ "perspectives.filter.live": "Live", "perspectives.filter.norec": "Not recorded only",
15507
+ "perspectives.col.case": "Case", "perspectives.col.mode": "Mode", "perspectives.col.status": "Status",
15508
+ "perspectives.noHit": "No matching cases.",
15509
+ "perspectives.updated": "Last updated:",
15510
+ "perspectives.empty": "No perspectives yet. Run ccqa perspectives, or record a test — it's created automatically.",
15511
+ "perspectives.loadFailed": "Loading perspectives failed",
15512
+ "perspectives.mode.deterministic": "deterministic", "perspectives.mode.live": "live",
15513
+ "perspectives.status.runnable": "runnable", "perspectives.status.notRecorded": "not recorded",
15514
+ "perspectives.metric.features": "Features", "perspectives.metric.cases": "Test cases",
15515
+ "perspectives.metric.deterministic": "Deterministic", "perspectives.metric.live": "Live",
15516
+ "perspectives.cov.runnable": "runnable", "perspectives.cov.notRecorded": "not recorded",
15517
+ "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
15518
+ "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
15519
+ "perspectives.d.relatedPaths": "Related code",
15520
+ "perspectives.note.label": "Note",
15521
+ "perspectives.note.placeholder": "Notes about this case…",
15522
+ "perspectives.note.saved": "Saved",
15523
+ "perspectives.note.error": "Could not save — retry",
15311
15524
  "prompt.card.record": "Recording browser actions",
15312
15525
  "prompt.card.live": "Live run (AI-driven)",
15313
15526
  "prompt.card.customPrompt": "Failure-cause classification",
@@ -15335,7 +15548,7 @@ const CLIENT_JS = `
15335
15548
  "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
15549
  },
15337
15550
  ja: {
15338
- "nav.projects": "プロジェクト", "nav.runs": "実行", "nav.secrets": "シークレット",
15551
+ "nav.projects": "プロジェクト", "nav.runs": "実行", "nav.perspectives": "Perspectives", "nav.secrets": "シークレット",
15339
15552
  "nav.prompts": "プロンプト", "nav.learning": "学習",
15340
15553
  "app.project": "プロジェクト", "app.profile": "プロファイル", "app.disconnect": "切断", "app.noProject": "プロジェクト未選択",
15341
15554
  "app.newProfile": "新規プロファイル",
@@ -15377,6 +15590,27 @@ const CLIENT_JS = `
15377
15590
  "learn.cta.desc": "採点した内容をもとに、ccqaが次回から同じように失敗の原因を分類できるよう学習します。",
15378
15591
  "learn.cta.run": "学習",
15379
15592
  "secrets.title": "シークレット", "prompts.title": "プロンプト", "learning.title": "学習",
15593
+ "perspectives.title": "Perspectives",
15594
+ "perspectives.search": "ケースを検索…",
15595
+ "perspectives.filter.all": "すべて", "perspectives.filter.deterministic": "決定的",
15596
+ "perspectives.filter.live": "ライブ", "perspectives.filter.norec": "未recordのみ",
15597
+ "perspectives.col.case": "ケース", "perspectives.col.mode": "モード", "perspectives.col.status": "状態",
15598
+ "perspectives.noHit": "該当するケースがありません。",
15599
+ "perspectives.updated": "最終更新:",
15600
+ "perspectives.empty": "まだperspectivesがありません。ccqa perspectives を実行するか、recordすると自動作成されます。",
15601
+ "perspectives.loadFailed": "perspectivesの読み込みに失敗しました",
15602
+ "perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
15603
+ "perspectives.status.runnable": "実行可能", "perspectives.status.notRecorded": "未record",
15604
+ "perspectives.metric.features": "機能", "perspectives.metric.cases": "テストケース",
15605
+ "perspectives.metric.deterministic": "決定的", "perspectives.metric.live": "ライブ",
15606
+ "perspectives.cov.runnable": "実行可能", "perspectives.cov.notRecorded": "未record",
15607
+ "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
15608
+ "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
15609
+ "perspectives.d.relatedPaths": "関連コード",
15610
+ "perspectives.note.label": "note",
15611
+ "perspectives.note.placeholder": "このケースについてのメモ…",
15612
+ "perspectives.note.saved": "保存しました",
15613
+ "perspectives.note.error": "保存に失敗しました — 再試行してください",
15380
15614
  "prompt.card.record": "ブラウザ操作の記録",
15381
15615
  "prompt.card.live": "ライブ実行(AI操作)",
15382
15616
  "prompt.card.customPrompt": "失敗原因の分類",
@@ -15632,8 +15866,8 @@ const CLIENT_JS = `
15632
15866
 
15633
15867
  // ── view routing ────────────────────────────────────────────────────
15634
15868
 
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" };
15869
+ var VIEWS = ["projects", "runs", "detail", "perspectives", "secrets", "prompts", "jobs"];
15870
+ 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
15871
  function showView(id) {
15638
15872
  // Any in-flight job poll belongs to the view we're leaving — bump the token
15639
15873
  // so its next tick is a no-op (see pollJob).
@@ -15651,6 +15885,7 @@ const CLIENT_JS = `
15651
15885
  function updateNavGate() {
15652
15886
  var gated = !state.project;
15653
15887
  document.querySelector(".nav-runs").classList.toggle("disabled", gated);
15888
+ document.querySelector(".nav-perspectives").classList.toggle("disabled", gated);
15654
15889
  document.querySelector(".nav-secrets").classList.toggle("disabled", gated);
15655
15890
  document.querySelector(".nav-prompts").classList.toggle("disabled", gated);
15656
15891
  document.querySelector(".nav-jobs").classList.toggle("disabled", gated);
@@ -15666,6 +15901,7 @@ const CLIENT_JS = `
15666
15901
  if (location.hash === "#/projects" || !state.project) { openProjects(); return; }
15667
15902
  var m = location.hash.match(/^#\\/runs\\/(.+)$/);
15668
15903
  if (m) { openRunDetail(decodeURIComponent(m[1])); return; }
15904
+ if (location.hash === "#/perspectives") { openPerspectives(); return; }
15669
15905
  if (location.hash === "#/secrets") { openSecrets(); return; }
15670
15906
  if (location.hash === "#/prompts") { openPrompts(); return; }
15671
15907
  var j = location.hash.match(/^#\\/jobs\\/(.+)$/);
@@ -16922,6 +17158,305 @@ const CLIENT_JS = `
16922
17158
  loadPrompts();
16923
17159
  }
16924
17160
 
17161
+ // ── perspectives ──────────────────────────────────────────────────────
17162
+ // A read-mostly coverage inventory the CLI generates ("ccqa perspectives" /
17163
+ // record); the hub UI's only write path is the per-case note (PATCH). The
17164
+ // whole document is fetched once per view-open and filtered/rendered
17165
+ // client-side — small enough that there is no pagination.
17166
+
17167
+ var perspState = { doc: null, q: "", f: "all" };
17168
+
17169
+ function perspectivesPath() {
17170
+ return "/api/v1/projects/" + encodeURIComponent(state.project) + "/perspectives";
17171
+ }
17172
+
17173
+ // GET the perspectives document. Resolves null on 404 (no document stored
17174
+ // yet — the normal "nothing recorded" state) instead of throwing, so
17175
+ // loadPerspectives can tell that apart from a real fetch/parse failure.
17176
+ function fetchPerspectives() {
17177
+ return fetch(perspectivesPath(), { headers: { Authorization: "Bearer " + state.token } }).then(function (res) {
17178
+ if (res.status === 404) return null;
17179
+ if (!res.ok) throw new Error(res.status + " " + res.statusText);
17180
+ return res.json();
17181
+ }, function () { throw new Error("Network unreachable — check the hub URL and your connection"); });
17182
+ }
17183
+
17184
+ // The execution mode lives inside the mechanically-derived status object
17185
+ // (spec.status.mode), not at the top level of a spec entry.
17186
+ function perspMode(spec) {
17187
+ return spec.status && spec.status.mode === "live" ? "live" : "deterministic";
17188
+ }
17189
+
17190
+ function perspRunnable(spec) {
17191
+ return perspMode(spec) === "live" || (spec.status && spec.status.generated === true);
17192
+ }
17193
+
17194
+ function setPerspStatus(message) {
17195
+ var box = document.getElementById("persp-status");
17196
+ box.hidden = !message;
17197
+ box.textContent = message || "";
17198
+ }
17199
+
17200
+ function perspModeChip(mode) {
17201
+ var span = el("span", "chip" + (mode === "live" ? " live" : ""));
17202
+ span.textContent = t(mode === "live" ? "perspectives.mode.live" : "perspectives.mode.deterministic");
17203
+ return span;
17204
+ }
17205
+
17206
+ function perspStatusBadge(spec) {
17207
+ var ok = perspRunnable(spec);
17208
+ var span = el("span", "badge " + (ok ? "ok" : "norec"));
17209
+ span.appendChild(el("span", "d"));
17210
+ span.appendChild(document.createTextNode(" " + t(ok ? "perspectives.status.runnable" : "perspectives.status.notRecorded")));
17211
+ return span;
17212
+ }
17213
+
17214
+ // Overview strip: feature/case counts, deterministic/live breakdown, and a
17215
+ // runnable-vs-not-recorded coverage bar.
17216
+ function renderPerspOverview(doc) {
17217
+ var host = document.getElementById("persp-ov");
17218
+ clear(host);
17219
+ var allSpecs = doc.features.reduce(function (acc, f) { return acc.concat(f.specs); }, []);
17220
+ var total = allSpecs.length;
17221
+ var ok = allSpecs.filter(perspRunnable).length;
17222
+ var no = total - ok;
17223
+ var det = allSpecs.filter(function (s) { return perspMode(s) === "deterministic"; }).length;
17224
+ var live = total - det;
17225
+
17226
+ function numBlock(value, labelKey) {
17227
+ var box = el("div", "num");
17228
+ box.appendChild(el("b", null, String(value)));
17229
+ box.appendChild(el("span", null, t(labelKey)));
17230
+ return box;
17231
+ }
17232
+
17233
+ host.appendChild(numBlock(doc.features.length, "perspectives.metric.features"));
17234
+ host.appendChild(el("div", "sep"));
17235
+ host.appendChild(numBlock(total, "perspectives.metric.cases"));
17236
+ host.appendChild(el("div", "sep"));
17237
+ var breakdown = el("div", "breakdown");
17238
+ breakdown.appendChild(numBlock(det, "perspectives.metric.deterministic"));
17239
+ breakdown.appendChild(numBlock(live, "perspectives.metric.live"));
17240
+ host.appendChild(breakdown);
17241
+
17242
+ var covwrap = el("div", "covwrap");
17243
+ var covbar = el("div", "covbar");
17244
+ if (total > 0) {
17245
+ var okBar = el("div", "ok");
17246
+ okBar.style.width = (ok / total) * 100 + "%";
17247
+ var noBar = el("div", "no");
17248
+ noBar.style.width = (no / total) * 100 + "%";
17249
+ covbar.appendChild(okBar);
17250
+ covbar.appendChild(noBar);
17251
+ }
17252
+ covwrap.appendChild(covbar);
17253
+ var covleg = el("div", "covleg");
17254
+ var legOk = el("span", "lg-ok");
17255
+ legOk.appendChild(el("i"));
17256
+ legOk.appendChild(document.createTextNode(t("perspectives.cov.runnable") + " " + ok));
17257
+ covleg.appendChild(legOk);
17258
+ if (no > 0) {
17259
+ var legNo = el("span", "lg-no");
17260
+ legNo.appendChild(el("i"));
17261
+ legNo.appendChild(document.createTextNode(t("perspectives.cov.notRecorded") + " " + no));
17262
+ covleg.appendChild(legNo);
17263
+ }
17264
+ covwrap.appendChild(covleg);
17265
+ host.appendChild(covwrap);
17266
+ }
17267
+
17268
+ function perspMatches(spec) {
17269
+ if (perspState.f === "deterministic" && perspMode(spec) !== "deterministic") return false;
17270
+ if (perspState.f === "live" && perspMode(spec) !== "live") return false;
17271
+ if (perspState.f === "norec" && perspRunnable(spec)) return false;
17272
+ if (perspState.q) {
17273
+ var hay = (spec.title + " " + (spec.summary || "") + " " + spec.specName).toLowerCase();
17274
+ if (hay.indexOf(perspState.q) === -1) return false;
17275
+ }
17276
+ return true;
17277
+ }
17278
+
17279
+ // Detail row: a definition list of the case's fields plus the note editor.
17280
+ // Built with createElement/textContent throughout — every field here is
17281
+ // API-derived, so none of it may go through innerHTML.
17282
+ function perspDetailContent(feature, spec) {
17283
+ var frag = document.createDocumentFragment();
17284
+ var dl = el("dl", "d-grid");
17285
+ function row(labelKey, valueNode) {
17286
+ dl.appendChild(el("dt", null, t(labelKey)));
17287
+ var dd = el("dd");
17288
+ if (typeof valueNode === "string") dd.textContent = valueNode;
17289
+ else dd.appendChild(valueNode);
17290
+ dl.appendChild(dd);
17291
+ }
17292
+ if (spec.preconditions && spec.preconditions.length) {
17293
+ var ul = el("ul");
17294
+ spec.preconditions.forEach(function (p) { ul.appendChild(el("li", null, p)); });
17295
+ row("perspectives.d.preconditions", ul);
17296
+ }
17297
+ if (spec.startScreen) row("perspectives.d.startScreen", spec.startScreen);
17298
+ if (spec.testCondition) row("perspectives.d.testCondition", spec.testCondition);
17299
+ var specCode = el("code", null, spec.specName);
17300
+ row("perspectives.d.spec", specCode);
17301
+ if (spec.relatedPaths && spec.relatedPaths.length) {
17302
+ var pathsWrap = el("span");
17303
+ spec.relatedPaths.forEach(function (p, i) {
17304
+ if (i > 0) pathsWrap.appendChild(document.createTextNode(" "));
17305
+ pathsWrap.appendChild(el("code", null, p));
17306
+ });
17307
+ row("perspectives.d.relatedPaths", pathsWrap);
17308
+ }
17309
+ frag.appendChild(dl);
17310
+
17311
+ var notebox = el("div", "notebox");
17312
+ notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
17313
+ var ta = el("textarea");
17314
+ ta.placeholder = t("perspectives.note.placeholder");
17315
+ ta.value = spec.note || "";
17316
+ notebox.appendChild(ta);
17317
+ var nact = el("div", "nact");
17318
+ var saveBtn = el("button", "btn primary", t("common.save"));
17319
+ saveBtn.type = "button";
17320
+ var statusEl = el("span", "nstatus");
17321
+ nact.appendChild(saveBtn);
17322
+ nact.appendChild(statusEl);
17323
+ notebox.appendChild(nact);
17324
+ frag.appendChild(notebox);
17325
+
17326
+ saveBtn.addEventListener("click", function () {
17327
+ saveBtn.disabled = true;
17328
+ statusEl.className = "nstatus";
17329
+ statusEl.textContent = "";
17330
+ apiFetch(perspectivesPath(), {
17331
+ method: "PATCH",
17332
+ headers: { "Content-Type": "application/json" },
17333
+ body: JSON.stringify({ feature: feature.featureName, spec: spec.specName, note: ta.value }),
17334
+ }).then(function () {
17335
+ spec.note = ta.value || undefined;
17336
+ statusEl.className = "nstatus ok";
17337
+ statusEl.textContent = t("perspectives.note.saved");
17338
+ }).catch(function (err) {
17339
+ statusEl.className = "nstatus err";
17340
+ statusEl.textContent = t("perspectives.note.error") + ": " + err.message;
17341
+ }).then(function () { saveBtn.disabled = false; });
17342
+ });
17343
+
17344
+ return frag;
17345
+ }
17346
+
17347
+ function renderPerspTable(doc) {
17348
+ var tbody = document.getElementById("persp-tbody");
17349
+ clear(tbody);
17350
+ var hits = 0;
17351
+ doc.features.forEach(function (feature) {
17352
+ var specs = feature.specs.filter(perspMatches);
17353
+ if (!specs.length) return;
17354
+ hits += specs.length;
17355
+
17356
+ var grpRow = el("tr", "grp");
17357
+ var grpTd = el("td");
17358
+ grpTd.colSpan = 4;
17359
+ grpTd.appendChild(document.createTextNode(feature.featureName));
17360
+ grpTd.appendChild(el("span", "gcount", specs.length + " " + t("perspectives.metric.cases").toLowerCase()));
17361
+ grpRow.appendChild(grpTd);
17362
+ tbody.appendChild(grpRow);
17363
+
17364
+ specs.forEach(function (spec) {
17365
+ var row = el("tr", "row");
17366
+ row.tabIndex = 0;
17367
+ row.setAttribute("aria-expanded", "false");
17368
+
17369
+ var titleTd = el("td", "c-title");
17370
+ titleTd.appendChild(document.createTextNode(spec.title));
17371
+ if (spec.summary) titleTd.appendChild(el("span", "csum", spec.summary));
17372
+ row.appendChild(titleTd);
17373
+
17374
+ var modeTd = el("td");
17375
+ modeTd.appendChild(perspModeChip(perspMode(spec)));
17376
+ row.appendChild(modeTd);
17377
+
17378
+ var statusTd = el("td");
17379
+ statusTd.appendChild(perspStatusBadge(spec));
17380
+ row.appendChild(statusTd);
17381
+
17382
+ var chevTd = el("td", "c-chev");
17383
+ chevTd.appendChild(el("span", "chev-i", "\\u25b6"));
17384
+ row.appendChild(chevTd);
17385
+
17386
+ var detailRow = el("tr", "detail");
17387
+ var detailTd = el("td");
17388
+ detailTd.colSpan = 4;
17389
+ detailRow.appendChild(detailTd);
17390
+ var built = false;
17391
+
17392
+ function toggle() {
17393
+ var open = detailRow.classList.toggle("open");
17394
+ row.setAttribute("aria-expanded", open ? "true" : "false");
17395
+ if (open && !built) {
17396
+ detailTd.appendChild(perspDetailContent(feature, spec));
17397
+ built = true;
17398
+ }
17399
+ }
17400
+ row.addEventListener("click", function (e) {
17401
+ if (e.target.tagName === "TEXTAREA" || e.target.tagName === "BUTTON") return;
17402
+ toggle();
17403
+ });
17404
+ row.addEventListener("keydown", function (e) {
17405
+ if ((e.key === "Enter" || e.key === " ") && e.target === row) { e.preventDefault(); toggle(); }
17406
+ });
17407
+
17408
+ tbody.appendChild(row);
17409
+ tbody.appendChild(detailRow);
17410
+ });
17411
+ });
17412
+ document.getElementById("persp-no-hit").hidden = hits > 0;
17413
+ }
17414
+
17415
+ function renderPerspectives() {
17416
+ var doc = perspState.doc;
17417
+ if (!doc) return;
17418
+ renderPerspOverview(doc);
17419
+ renderPerspTable(doc);
17420
+ }
17421
+
17422
+ function setPerspUpdated(doc) {
17423
+ var span = document.getElementById("persp-updated");
17424
+ if (doc && doc.generatedAt) {
17425
+ span.hidden = false;
17426
+ span.textContent = t("perspectives.updated") + " " + relTime(doc.generatedAt);
17427
+ } else {
17428
+ span.hidden = true;
17429
+ span.textContent = "";
17430
+ }
17431
+ }
17432
+
17433
+ function loadPerspectives() {
17434
+ setPerspStatus("");
17435
+ document.getElementById("persp-body").hidden = true;
17436
+ setPerspUpdated(null);
17437
+ fetchPerspectives()
17438
+ .then(function (doc) {
17439
+ perspState.doc = doc;
17440
+ if (!doc) { setPerspStatus(t("perspectives.empty")); return; }
17441
+ setPerspUpdated(doc);
17442
+ document.getElementById("persp-body").hidden = false;
17443
+ renderPerspectives();
17444
+ })
17445
+ .catch(function (err) {
17446
+ perspState.doc = null;
17447
+ setPerspStatus(t("perspectives.loadFailed") + ": " + err.message);
17448
+ });
17449
+ }
17450
+
17451
+ function openPerspectives() {
17452
+ showView("perspectives");
17453
+ document.getElementById("persp-q").value = perspState.q;
17454
+ document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
17455
+ b.setAttribute("aria-pressed", String(b.getAttribute("data-f") === perspState.f));
17456
+ });
17457
+ loadPerspectives();
17458
+ }
17459
+
16925
17460
  // The custom prompt body is JSON (schemaVersion/basePromptVersion/customPromptVersion/
16926
17461
  // generatedAt/guidance); the textarea only ever shows the learned guidance
16927
17462
  // text, never the raw JSON. A parse failure falls back to the raw text so a
@@ -17313,6 +17848,22 @@ const CLIENT_JS = `
17313
17848
  // argument (which would render "[object PointerEvent]" in the status box).
17314
17849
  document.getElementById("sec-load").addEventListener("click", function () { loadSecrets(); });
17315
17850
 
17851
+ // perspectives view
17852
+ document.getElementById("persp-refresh").addEventListener("click", function () { loadPerspectives(); });
17853
+ document.getElementById("persp-q").addEventListener("input", function (e) {
17854
+ perspState.q = e.target.value.trim().toLowerCase();
17855
+ renderPerspectives();
17856
+ });
17857
+ document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
17858
+ b.addEventListener("click", function () {
17859
+ perspState.f = b.getAttribute("data-f");
17860
+ document.querySelectorAll("#view-perspectives .fchip").forEach(function (x) {
17861
+ x.setAttribute("aria-pressed", x === b ? "true" : "false");
17862
+ });
17863
+ renderPerspectives();
17864
+ });
17865
+ });
17866
+
17316
17867
  // prompts view
17317
17868
  document.getElementById("pr-load").addEventListener("click", function () { loadPrompts(); });
17318
17869
 
@@ -17717,6 +18268,11 @@ function registerRoutes(router, config, queue) {
17717
18268
  router.get("/api/v1/projects/:project/prompts", createListPromptsHandler(promptConfig));
17718
18269
  router.get("/api/v1/projects/:project/prompts/:name", createGetPromptHandler(promptConfig));
17719
18270
  router.delete("/api/v1/projects/:project/prompts/:name", createDeletePromptHandler(promptConfig));
18271
+ const perspectivesConfig = { store: storage.perspectives };
18272
+ router.put("/api/v1/projects/:project/perspectives", createPutPerspectivesHandler(perspectivesConfig));
18273
+ router.get("/api/v1/projects/:project/perspectives", createGetPerspectivesHandler(perspectivesConfig));
18274
+ router.patch("/api/v1/projects/:project/perspectives", createPatchPerspectivesNoteHandler(perspectivesConfig));
18275
+ router.delete("/api/v1/projects/:project/perspectives", createDeletePerspectivesHandler(perspectivesConfig));
17720
18276
  router.post("/api/v1/projects/:project/learning-jobs", createCreateLearningJobHandler({
17721
18277
  storage,
17722
18278
  queue
@@ -17918,6 +18474,12 @@ function promptBlobPath(root, project, name) {
17918
18474
  function promptMetaPath(root, project, name) {
17919
18475
  return join(promptProjectDir(root, project), `${name}.meta.json`);
17920
18476
  }
18477
+ function perspectivesKindDir(root) {
18478
+ return join(root, "perspectives");
18479
+ }
18480
+ function perspectivesPath(root, project) {
18481
+ return join(perspectivesKindDir(root), `${project}.json`);
18482
+ }
17921
18483
  //#endregion
17922
18484
  //#region src/hub/core/storage/file/artifact-store.ts
17923
18485
  /**
@@ -18005,6 +18567,41 @@ function createFileJobStore(root) {
18005
18567
  };
18006
18568
  }
18007
18569
  //#endregion
18570
+ //#region src/hub/core/storage/file/perspectives-store.ts
18571
+ /**
18572
+ * Defense-in-depth path validation: the HTTP layer already checks the project
18573
+ * segment, but this builds a file path from it, so it re-checks rather than
18574
+ * trusting callers. Mirrors the sibling prompt/secret stores.
18575
+ */
18576
+ function assertSafeName$2(value, label) {
18577
+ 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 '..'`);
18578
+ }
18579
+ /**
18580
+ * Perspectives storage: one JSON document per project, plain UTF-8 with no
18581
+ * encryption (an inventory of what is tested is not a secret). No meta file —
18582
+ * the document's own `generatedAt` is its timestamp.
18583
+ */
18584
+ function createFilePerspectivesStore(root) {
18585
+ return {
18586
+ async put(project, blob) {
18587
+ assertSafeName$2(project, "project");
18588
+ await writeBytes(perspectivesPath(root, project), blob);
18589
+ },
18590
+ async get(project) {
18591
+ assertSafeName$2(project, "project");
18592
+ return readBytesOrNull(perspectivesPath(root, project));
18593
+ },
18594
+ async update(project, mutate) {
18595
+ assertSafeName$2(project, "project");
18596
+ await updateJson(perspectivesPath(root, project), mutate);
18597
+ },
18598
+ async delete(project) {
18599
+ assertSafeName$2(project, "project");
18600
+ await removePath(perspectivesPath(root, project));
18601
+ }
18602
+ };
18603
+ }
18604
+ //#endregion
18008
18605
  //#region src/hub/core/storage/file/prompt-store.ts
18009
18606
  /**
18010
18607
  * Defense-in-depth path validation: the HTTP layer already checks project/name,
@@ -18222,6 +18819,7 @@ function createFileHubStorage(dataDir) {
18222
18819
  variables: createFileSecretStore(dataDir, "variables"),
18223
18820
  triage: createFileTriageStore(dataDir),
18224
18821
  prompts: createFilePromptStore(dataDir),
18822
+ perspectives: createFilePerspectivesStore(dataDir),
18225
18823
  jobs: createFileJobStore(dataDir)
18226
18824
  };
18227
18825
  }