ccqa 0.5.1 → 0.6.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/README.md CHANGED
@@ -78,6 +78,7 @@ ccqa run tasks/create-and-complete --drift --format github
78
78
  | Assertion helper functions | [Assertions](./docs/assertions.md) |
79
79
  | Auto-fix failing tests | [Auto-fix](./docs/auto-fix.md) |
80
80
  | Detect spec/code drift in CI | [Drift](./docs/drift.md) |
81
+ | Inventory existing test coverage | [Perspectives](./docs/perspectives.md) |
81
82
 
82
83
  ## Commands
83
84
 
@@ -87,9 +88,10 @@ ccqa trace <feature/spec> Record browser actions for a spec (inlines an
87
88
  ccqa generate <feature/spec> Generate test script from recorded actions
88
89
  ccqa run [feature/spec] Execute generated test scripts (add --drift to analyze failures)
89
90
  ccqa drift [feature/spec] Standalone spec ↔ codebase drift audit (for scheduled jobs)
91
+ ccqa perspectives Inventory existing test coverage into .ccqa/perspectives.yaml
90
92
  ```
91
93
 
92
- All Claude-driven commands accept `-m, --model <name>` (alias `sonnet` | `opus` | `haiku`, or a full model ID). The flag overrides `CCQA_MODEL`; when both are unset, the Claude Code CLI default is used. Interactive commands authenticate via your local Claude Code login; commands that talk to Claude in CI (`ccqa run --drift`, `ccqa drift`) additionally honor `ANTHROPIC_API_KEY`.
94
+ All Claude-driven commands accept `-m, --model <name>` (alias `sonnet` | `opus` | `haiku`, or a full model ID). The flag overrides `CCQA_MODEL`; when both are unset, the Claude Code CLI default is used. They also accept `--language <bcp47>` (e.g. `ja`, `en`) to set the language of human-readable output; the default `auto` follows the language of the spec/codebase. Interactive commands authenticate via your local Claude Code login; commands that talk to Claude in CI (`ccqa run --drift`, `ccqa drift`) additionally honor `ANTHROPIC_API_KEY`.
93
95
 
94
96
  `<feature/spec>` is a 2-segment alias for the on-disk path `.ccqa/features/<feature>/test-cases/<spec>/`.
95
97
 
@@ -97,11 +99,14 @@ All Claude-driven commands accept `-m, --model <name>` (alias `sonnet` | `opus`
97
99
 
98
100
  ```
99
101
  .ccqa/
102
+ perspectives.yaml # Inventory of existing coverage (machine-readable, canonical)
103
+ perspectives.md # Category index, regenerated from the YAML
100
104
  blocks/
101
105
  login/
102
106
  spec.yaml # Reusable block (params + steps)
103
107
  features/
104
108
  tasks/
109
+ perspectives.md # Per-category detail tables (one per case)
105
110
  test-cases/
106
111
  create-and-complete/
107
112
  spec.yaml # Test definition
package/dist/bin/ccqa.mjs CHANGED
@@ -562,6 +562,71 @@ function isParamRequired(param) {
562
562
  return param.required !== false;
563
563
  }
564
564
  //#endregion
565
+ //#region src/spec/perspectives-schema.ts
566
+ /**
567
+ * `perspectives.yaml` is an inventory of the test coverage that already
568
+ * exists under `.ccqa/` — the ccqa equivalent of a hand-kept QA spreadsheet,
569
+ * but scoped deliberately to *facts about what is tested today*.
570
+ *
571
+ * It intentionally does NOT carry severity / importance / priority. Deciding
572
+ * "how badly does it hurt the customer if this breaks" is a human + PdM
573
+ * decision, not something ccqa should author or silently overwrite. Keeping
574
+ * those columns out of the schema (and `.strict()` rejecting them) makes the
575
+ * boundary explicit: perspectives is a factual stock-take, severity lives
576
+ * wherever the team decides on it.
577
+ *
578
+ * It also does NOT attempt code-vs-test gap analysis (listing untested
579
+ * areas). A flat dump of "things in code with no test" is noise without
580
+ * prioritisation; that is a separate, later concern.
581
+ */
582
+ /**
583
+ * Whether the spec has been traced / generated. Both are derived mechanically
584
+ * by the CLI from on-disk artifacts (actions.json / test.spec.ts), never
585
+ * written by Claude — these are facts and must not drift.
586
+ */
587
+ const PerspectiveStatusSchema = z.object({
588
+ traced: z.boolean(),
589
+ generated: z.boolean()
590
+ }).strict();
591
+ /**
592
+ * One test case in the inventory.
593
+ *
594
+ * - `title` / `relatedPaths` are transcribed verbatim from the spec.yaml.
595
+ * - `status` is mechanically derived (see PerspectiveStatusSchema).
596
+ * - `summary` is a 1–2 sentence description of *what the spec verifies*,
597
+ * derived from its steps by Claude.
598
+ * - `startScreen` / `testCondition` / `preconditions` mirror the columns a
599
+ * hand-kept QA table carries. They are Claude-derived from the spec's
600
+ * steps (the opening screen, the state the test assumes, and the setup
601
+ * prerequisites such as which role logs in). Optional: a spec may not
602
+ * express all of them.
603
+ * - `note` is a human-only field. Regenerating perspectives preserves it.
604
+ *
605
+ * The detailed test procedure and expected results are deliberately NOT
606
+ * duplicated here — the spec.yaml steps are the single source of truth for
607
+ * those. The Markdown view links back to the spec instead of restating them.
608
+ */
609
+ const PerspectiveSpecSchema = z.object({
610
+ specName: z.string().min(1),
611
+ title: z.string().min(1),
612
+ summary: z.string(),
613
+ startScreen: z.string().optional(),
614
+ testCondition: z.string().optional(),
615
+ preconditions: z.array(z.string().min(1)).optional(),
616
+ relatedPaths: z.array(z.string().min(1)).optional(),
617
+ status: PerspectiveStatusSchema,
618
+ note: z.string().optional()
619
+ }).strict();
620
+ const PerspectiveFeatureSchema = z.object({
621
+ featureName: z.string().min(1),
622
+ specs: z.array(PerspectiveSpecSchema)
623
+ }).strict();
624
+ /** Top-level perspectives schema. `.strict()` rejects any unknown key. */
625
+ const PerspectivesSchema = z.object({
626
+ generatedAt: z.string().optional(),
627
+ features: z.array(PerspectiveFeatureSchema)
628
+ }).strict();
629
+ //#endregion
565
630
  //#region src/types.ts
566
631
  const RouteStepSchema = z.object({
567
632
  title: z.string(),
@@ -633,7 +698,7 @@ const DraftIssueSchema = z.object({
633
698
  ]),
634
699
  stepId: z.string().nullable(),
635
700
  message: z.string(),
636
- detail: z.string().optional()
701
+ detail: z.string().nullish()
637
702
  });
638
703
  const DraftReportSchema = z.object({
639
704
  issues: z.array(DraftIssueSchema),
@@ -1205,6 +1270,8 @@ function collectIncludedBlockNames(spec) {
1205
1270
  //#region src/store/index.ts
1206
1271
  const CCQA_DIR = ".ccqa";
1207
1272
  const SPEC_FILE = "spec.yaml";
1273
+ const PERSPECTIVES_FILE = "perspectives.yaml";
1274
+ const PERSPECTIVES_MD_FILE = "perspectives.md";
1208
1275
  function getCcqaDir(cwd = process.cwd()) {
1209
1276
  return join(cwd, CCQA_DIR);
1210
1277
  }
@@ -1250,6 +1317,56 @@ async function saveSpecFile(featureName, specName, content, cwd) {
1250
1317
  await writeFile(specPath, content.endsWith("\n") ? content : content + "\n", "utf-8");
1251
1318
  return specPath;
1252
1319
  }
1320
+ /** Absolute path to the single repo-wide `.ccqa/perspectives.yaml`. */
1321
+ function getPerspectivesPath(cwd) {
1322
+ return join(getCcqaDir(cwd), PERSPECTIVES_FILE);
1323
+ }
1324
+ /**
1325
+ * Read `.ccqa/perspectives.yaml` raw. Returns null when the file does not
1326
+ * exist (first-ever generation) so callers can treat it as optional.
1327
+ */
1328
+ async function tryReadPerspectives(cwd) {
1329
+ return readFile(getPerspectivesPath(cwd), "utf-8").catch(() => null);
1330
+ }
1331
+ /**
1332
+ * Write `.ccqa/perspectives.yaml`. Mirrors `saveSpecFile`: ensures the
1333
+ * directory exists and the content ends in a trailing newline.
1334
+ */
1335
+ async function savePerspectives(content, cwd) {
1336
+ await mkdir(getCcqaDir(cwd), { recursive: true });
1337
+ const path = getPerspectivesPath(cwd);
1338
+ await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
1339
+ return path;
1340
+ }
1341
+ /**
1342
+ * Human-readable Markdown companion to perspectives.yaml. The `.yaml` is the
1343
+ * machine-readable source of truth; the `.md` is a rendered view for review.
1344
+ */
1345
+ function getPerspectivesMarkdownPath(cwd) {
1346
+ return join(getCcqaDir(cwd), PERSPECTIVES_MD_FILE);
1347
+ }
1348
+ async function savePerspectivesMarkdown(content, cwd) {
1349
+ await mkdir(getCcqaDir(cwd), { recursive: true });
1350
+ const path = getPerspectivesMarkdownPath(cwd);
1351
+ await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
1352
+ return path;
1353
+ }
1354
+ /**
1355
+ * Per-category detail view: `.ccqa/features/<feature>/perspectives.md`. The
1356
+ * root `perspectives.md` is a thin category index that links here; this file
1357
+ * carries the full per-case tables for one feature. The feature dir already
1358
+ * exists (it holds the test cases), but `mkdir -p` keeps this safe when called
1359
+ * in isolation.
1360
+ */
1361
+ function getFeaturePerspectivesMarkdownPath(featureName, cwd) {
1362
+ return join(getFeatureDir(featureName, cwd), PERSPECTIVES_MD_FILE);
1363
+ }
1364
+ async function saveFeaturePerspectivesMarkdown(featureName, content, cwd) {
1365
+ await mkdir(getFeatureDir(featureName, cwd), { recursive: true });
1366
+ const path = getFeaturePerspectivesMarkdownPath(featureName, cwd);
1367
+ await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf-8");
1368
+ return path;
1369
+ }
1253
1370
  /**
1254
1371
  * Replace (or insert) the `relatedPaths` key in the spec. Preserves every
1255
1372
  * other top-level field and the entire steps array. Returns the absolute
@@ -2188,16 +2305,60 @@ function formatUnstableDrop(drop) {
2188
2305
  return `${`${action.command}${action.assertType ? " " + action.assertType : ""}`}: contains unstable literal (${ids}) — ${samples}`;
2189
2306
  }
2190
2307
  //#endregion
2308
+ //#region src/prompts/language.ts
2309
+ /**
2310
+ * Shared language handling for every Claude-driven command. Each command
2311
+ * writes some human-readable text (drift findings, trace observations, draft
2312
+ * prose, diagnose hints, perspectives summaries), so the language policy is a
2313
+ * single cross-cutting concern rather than per-command logic.
2314
+ *
2315
+ * The value is a BCP-47 tag (e.g. "ja", "en") or the sentinel "auto". With
2316
+ * "auto" the model follows the language of the material it is given — Japanese
2317
+ * specs/codebase yield Japanese output — and `languageDirective` returns an
2318
+ * empty string so prompts stay byte-identical to the no-flag baseline.
2319
+ */
2320
+ const DEFAULT_LANGUAGE = "auto";
2321
+ /**
2322
+ * The instruction appended to a command's system prompt. Empty for "auto"
2323
+ * (and undefined / blank), so the model keeps its natural material-following
2324
+ * behaviour; otherwise it pins every human-readable field to the given tag.
2325
+ */
2326
+ function languageDirective(language) {
2327
+ const lang = (language ?? "auto").trim();
2328
+ if (lang === "" || lang === "auto") return "";
2329
+ return `\n\nIMPORTANT: Write every human-readable field, message, and explanation in **${lang}** (BCP-47 language tag), regardless of the language of the spec or codebase.`;
2330
+ }
2331
+ /**
2332
+ * Whether the CLI's own interactive prompts (the strings ccqa prints itself,
2333
+ * not the model's output) should be Japanese. Only an explicit Japanese tag
2334
+ * (`ja`, `ja-JP`, …) opts in; `auto` (the default) and every other tag keep
2335
+ * the English prompts, so an English user running with no flag is unaffected.
2336
+ */
2337
+ function useJapanesePrompts(language) {
2338
+ return /^ja\b/i.test((language ?? "").trim());
2339
+ }
2340
+ //#endregion
2341
+ //#region src/cli/options.ts
2342
+ /**
2343
+ * Shared `--language` flag. Every Claude-driven command writes some
2344
+ * human-readable text, so language is a cross-cutting concern handled the same
2345
+ * way everywhere — much like `--model`. The value is a BCP-47 tag (e.g. "ja",
2346
+ * "en") or "auto" (default), which follows the language of the material.
2347
+ */
2348
+ function addLanguageOption(command) {
2349
+ return command.option("--language <bcp47>", "Language for human-readable output (e.g. 'en', 'ja'). Default 'auto' follows the language of the spec/codebase.", DEFAULT_LANGUAGE);
2350
+ }
2351
+ //#endregion
2191
2352
  //#region src/cli/trace.ts
2192
2353
  const VALIDATION_MODES = ["lenient", "strict"];
2193
- const traceCommand = new Command("trace").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Run agent-browser, verify assertions, and record structured actions").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 with a warning but keeps them; 'strict' drops them from actions.json.", (raw) => {
2354
+ const traceCommand = addLanguageOption(new Command("trace").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Run agent-browser, verify assertions, and record structured actions").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 with a warning but keeps them; 'strict' drops them from actions.json.", (raw) => {
2194
2355
  if (VALIDATION_MODES.includes(raw)) return raw;
2195
2356
  throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
2196
- }, "lenient").action(async (specPath, opts) => {
2357
+ }, "lenient")).action(async (specPath, opts) => {
2197
2358
  const { featureName, specName } = parseSpecPath(specPath);
2198
- await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient");
2359
+ await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient", opts.language);
2199
2360
  });
2200
- async function runTrace(featureName, specName, model, validationMode = "lenient") {
2361
+ async function runTrace(featureName, specName, model, validationMode = "lenient", language) {
2201
2362
  header("trace", `${featureName}/${specName}`);
2202
2363
  try {
2203
2364
  meta("agent-browser", assertAgentBrowserAvailable());
@@ -2228,7 +2389,7 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
2228
2389
  });
2229
2390
  const userPrompt = await loadTraceUserPrompt();
2230
2391
  if (userPrompt !== null) meta("user-prompt", ".ccqa/prompts/trace.user.md");
2231
- const systemPrompt = userPrompt === null ? baseSystemPrompt : `${baseSystemPrompt}\n## Project-specific guidance\n\n${userPrompt}\n`;
2392
+ const systemPrompt = (userPrompt === null ? baseSystemPrompt : `${baseSystemPrompt}\n## Project-specific guidance\n\n${userPrompt}\n`) + languageDirective(language);
2232
2393
  const prompt = buildTracePrompt(spec.title);
2233
2394
  info("Running agent-browser session...");
2234
2395
  blank();
@@ -3219,16 +3380,24 @@ function previewDiff(before, after) {
3219
3380
  //#endregion
3220
3381
  //#region src/diagnose/prompt.ts
3221
3382
  function buildDiagnosePrompt(input) {
3222
- const { script, specYaml, actions, failureLog, pageSnapshot, outputLanguage = "en" } = input;
3383
+ const { script, specYaml, actions, failureLog, pageSnapshot, outputLanguage = "auto" } = input;
3223
3384
  const numbered = script.split("\n").map((l, i) => `${i + 1}: ${l}`).join("\n");
3385
+ const actionsSummary = actions.map((a, i) => {
3386
+ const parts = [`${i + 1}. ${a.command}`];
3387
+ if (a.assertType) parts.push(`assertType="${a.assertType}"`);
3388
+ if (a.selector) parts.push(`selector="${a.selector}"`);
3389
+ if (a.value) parts.push(`value="${a.value}"`);
3390
+ if (a.observation) parts.push(`→ ${a.observation}`);
3391
+ return parts.join(" ");
3392
+ }).join("\n");
3224
3393
  return `You are diagnosing a failing E2E test. The test was generated from a recorded trace of the original interaction. Compare the failing run against the original spec and recorded actions to determine WHY the test failed and what the right fix is.
3225
3394
 
3226
- ## Output language
3395
+ ${outputLanguage === "auto" ? "" : `## Output language
3227
3396
 
3228
3397
  Write all human-readable fields (\`reasoning\`, \`reason\`) in **${outputLanguage}** (BCP-47 tag).
3229
3398
  Selectors, file paths, identifiers, code, type names (TIMING_ISSUE, etc.), JSON keys, and quoted strings stay verbatim regardless of language.
3230
3399
 
3231
- ## You have read-only filesystem tools
3400
+ `}## You have read-only filesystem tools
3232
3401
 
3233
3402
  You can call \`Grep\`, \`Glob\`, and \`Read\` against the current repository before producing the JSON.
3234
3403
 
@@ -3317,14 +3486,7 @@ Pick exactly ONE category. The output JSON must follow the shape for that catego
3317
3486
  ${specYaml}
3318
3487
 
3319
3488
  ## Recorded Actions (actions.json summary)
3320
- ${actions.map((a, i) => {
3321
- const parts = [`${i + 1}. ${a.command}`];
3322
- if (a.assertType) parts.push(`assertType="${a.assertType}"`);
3323
- if (a.selector) parts.push(`selector="${a.selector}"`);
3324
- if (a.value) parts.push(`value="${a.value}"`);
3325
- if (a.observation) parts.push(`→ ${a.observation}`);
3326
- return parts.join(" ");
3327
- }).join("\n")}
3489
+ ${actionsSummary}
3328
3490
 
3329
3491
  ## Test Script (with line numbers)
3330
3492
  ${numbered}
@@ -3901,11 +4063,11 @@ function resolveMode(opts) {
3901
4063
  }
3902
4064
  //#endregion
3903
4065
  //#region src/cli/generate.ts
3904
- const generateCommand = new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate agent-browser test script from recorded trace actions. test.spec.ts is regenerated from actions.json on every run; pass --force to overwrite manual edits.").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--auto", "Apply auto-fixes without confirmation regardless of confidence (CI use)").option("--no-interactive", "Never prompt; only auto-apply when confidence is high, otherwise give up").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("--language <bcp47>", "Language for diagnose reasoning / hint text (e.g. 'en', 'ja')", "en").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").action(async (specPath, opts) => {
4066
+ const generateCommand = addLanguageOption(new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate agent-browser test script from recorded trace actions. test.spec.ts is regenerated from actions.json on every run; pass --force to overwrite manual edits.").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--auto", "Apply auto-fixes without confirmation regardless of confidence (CI use)").option("--no-interactive", "Never prompt; only auto-apply when confidence is high, otherwise give up").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("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.")).action(async (specPath, opts) => {
3905
4067
  const { featureName, specName } = parseSpecPath(specPath);
3906
4068
  const mode = resolveMode(opts);
3907
4069
  const useSnapshot = opts.snapshot !== false;
3908
- await runGenerate(featureName, specName, parseInt(opts.maxRetries, 10), mode, opts.force ?? false, useSnapshot, opts.language ?? "en", opts.model);
4070
+ await runGenerate(featureName, specName, parseInt(opts.maxRetries, 10), mode, opts.force ?? false, useSnapshot, opts.language ?? "auto", opts.model);
3909
4071
  });
3910
4072
  async function runGenerate(featureName, specName, maxRetries, mode, force, useSnapshot, outputLanguage, model) {
3911
4073
  header("generate", `${featureName}/${specName}`);
@@ -4395,7 +4557,7 @@ const DEFAULT_CONCURRENCY$1 = 3;
4395
4557
  * `cli/run` calls this with just the failing specs after vitest.
4396
4558
  */
4397
4559
  async function analyzeDrift(input) {
4398
- const { targets, cwd, blocks, concurrency = DEFAULT_CONCURRENCY$1, model, onSpecStart } = input;
4560
+ const { targets, cwd, blocks, concurrency = DEFAULT_CONCURRENCY$1, model, language, onSpecStart } = input;
4399
4561
  const results = new Array(targets.length);
4400
4562
  let cursor = 0;
4401
4563
  const worker = async () => {
@@ -4407,7 +4569,8 @@ async function analyzeDrift(input) {
4407
4569
  results[idx] = await checkSpec(target, {
4408
4570
  cwd,
4409
4571
  blocks,
4410
- model
4572
+ model,
4573
+ language
4411
4574
  });
4412
4575
  }
4413
4576
  };
@@ -4426,7 +4589,7 @@ async function checkSpec(target, opts) {
4426
4589
  };
4427
4590
  const { result, isError } = await invokeClaudeStreaming({
4428
4591
  prompt: buildDriftUserPrompt(existing),
4429
- systemPrompt: buildDriftSystemPrompt(opts.blocks),
4592
+ systemPrompt: buildDriftSystemPrompt(opts.blocks) + languageDirective(opts.language),
4430
4593
  allowedTools: [
4431
4594
  "Read",
4432
4595
  "Grep",
@@ -4634,7 +4797,7 @@ async function resolveVitestConfig() {
4634
4797
  return bundledVitestConfigPath();
4635
4798
  }
4636
4799
  }
4637
- const runCommand = new Command("run").argument("[target]", "Spec to run: '<feature>/<spec>', '<feature>', or omit for all").description("Run generated agent-browser test scripts. Pass --drift to invoke a Claude-driven drift analysis on each failing spec (skipped silently when no test fails). Requires ANTHROPIC_API_KEY or a local Claude login.").option("--drift", "On vitest failure, run drift analysis on the failing specs").option("--drift-strict", "Treat drift ERROR findings as a run failure (exit 1 even if vitest passed). Implies --drift.").option("--format <fmt>", "Output format for the drift block: text | json | github", "text").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Used by --drift only. Overrides CCQA_MODEL.").action(async (target, opts) => {
4800
+ const runCommand = addLanguageOption(new Command("run").argument("[target]", "Spec to run: '<feature>/<spec>', '<feature>', or omit for all").description("Run generated agent-browser test scripts. Pass --drift to invoke a Claude-driven drift analysis on each failing spec (skipped silently when no test fails). Requires ANTHROPIC_API_KEY or a local Claude login.").option("--drift", "On vitest failure, run drift analysis on the failing specs").option("--drift-strict", "Treat drift ERROR findings as a run failure (exit 1 even if vitest passed). Implies --drift.").option("--format <fmt>", "Output format for the drift block: text | json | github", "text").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Used by --drift only. Overrides CCQA_MODEL.")).action(async (target, opts) => {
4638
4801
  await runTests(target, opts);
4639
4802
  });
4640
4803
  async function runTests(target, opts) {
@@ -4754,6 +4917,7 @@ async function maybeRunDrift(summaries, opts, currentExitCode) {
4754
4917
  blocks: await loadAvailableBlocks(cwd),
4755
4918
  concurrency: Math.min(3, targets.length),
4756
4919
  ...opts.model ? { model: opts.model } : {},
4920
+ ...opts.language ? { language: opts.language } : {},
4757
4921
  onSpecStart: (t) => {
4758
4922
  if (format === "text") info(`drift: checking ${t.featureName}/${t.specName}`);
4759
4923
  }
@@ -4866,7 +5030,7 @@ async function resolveSpecs(target) {
4866
5030
  //#endregion
4867
5031
  //#region src/cli/draft.ts
4868
5032
  const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
4869
- const draftCommand = 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) => {
5033
+ 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) => {
4870
5034
  await ensureCcqaDir();
4871
5035
  let featureName;
4872
5036
  let specName;
@@ -4882,6 +5046,7 @@ const draftCommand = new Command("draft").argument("[feature/spec]", "Optional s
4882
5046
  });
4883
5047
  async function runDraft(featureName, specName, opts, prefilledIntent) {
4884
5048
  header("draft", `${featureName}/${specName}`);
5049
+ const ja = useJapanesePrompts(opts.language);
4885
5050
  const oneShot = opts.instruction !== void 0;
4886
5051
  let useIntentOnce = prefilledIntent !== null && !oneShot;
4887
5052
  while (true) {
@@ -4892,7 +5057,7 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
4892
5057
  else if (useIntentOnce && isFirstRun) {
4893
5058
  userInput = prefilledIntent ?? "";
4894
5059
  useIntentOnce = false;
4895
- } else userInput = await prompt(isFirstRun ? "What do you want to test? > " : "How would you like to refine? (empty = re-validate) > ");
5060
+ } else userInput = await prompt(isFirstRun ? ja ? "何をテストしたいですか? > " : "What do you want to test? > " : ja ? "どのように修正しますか? (空欄で再検証) > " : "How would you like to refine? (empty = re-validate) > ");
4896
5061
  if (isFirstRun && !userInput.trim()) {
4897
5062
  error("intent required for the first draft (no spec exists yet)");
4898
5063
  process.exit(1);
@@ -4902,11 +5067,12 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
4902
5067
  specName,
4903
5068
  existing,
4904
5069
  userInput: userInput.trim(),
4905
- autoApply: opts.apply === true
5070
+ autoApply: opts.apply === true,
5071
+ language: opts.language
4906
5072
  });
4907
5073
  if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
4908
5074
  blank();
4909
- if (/^y/i.test(await prompt("Are you done with this draft? [y/N] "))) {
5075
+ if (/^y/i.test(await prompt(ja ? "このドラフトは完了ですか? [y/N] " : "Are you done with this draft? [y/N] "))) {
4910
5076
  info("draft session complete.");
4911
5077
  hint(`run 'ccqa trace ${featureName}/${specName}' to record actions`);
4912
5078
  process.exit(0);
@@ -4914,9 +5080,9 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
4914
5080
  }
4915
5081
  }
4916
5082
  async function runOneTurn(input) {
4917
- const { featureName, specName, existing, userInput, autoApply } = input;
5083
+ const { featureName, specName, existing, userInput, autoApply, language } = input;
4918
5084
  const isFirstRun = existing === null;
4919
- const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks());
5085
+ const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks()) + languageDirective(language);
4920
5086
  const userPrompt = buildDraftPrompt({
4921
5087
  mode: isFirstRun ? "create" : "refine",
4922
5088
  existing: existing ?? "",
@@ -4979,7 +5145,7 @@ async function runOneTurn(input) {
4979
5145
  info("--- proposed changes ---");
4980
5146
  printUnifiedDiff(original, report.patch);
4981
5147
  blank();
4982
- if (!(autoApply ? true : /^y/i.test(await prompt("Apply this patch? [y/N] ")))) {
5148
+ if (!(autoApply ? true : /^y/i.test(await prompt(useJapanesePrompts(language) ? "このパッチを適用しますか? [y/N] " : "Apply this patch? [y/N] ")))) {
4983
5149
  info("aborted — no changes applied.");
4984
5150
  return {
4985
5151
  hasError,
@@ -5071,8 +5237,9 @@ function writeFinding(issue) {
5071
5237
  if (issue.detail) process.stdout.write(` └ ${issue.detail.replace(/\n/g, "\n ")}\n`);
5072
5238
  }
5073
5239
  async function proposeNaming(opts) {
5240
+ const ja = useJapanesePrompts(opts.language);
5074
5241
  const oneShot = opts.instruction !== void 0;
5075
- const intent = oneShot ? opts.instruction ?? "" : await prompt("What do you want to test? > ");
5242
+ const intent = oneShot ? opts.instruction ?? "" : await prompt(ja ? "何をテストしたいですか? > " : "What do you want to test? > ");
5076
5243
  if (!intent.trim()) {
5077
5244
  error("intent required to propose a feature/spec name");
5078
5245
  process.exit(1);
@@ -5124,13 +5291,13 @@ async function proposeNaming(opts) {
5124
5291
  naming: final,
5125
5292
  intent: intent.trim()
5126
5293
  };
5127
- const answer = await prompt(`Use this name? [y/N/edit] > `);
5294
+ const answer = await prompt(ja ? "この名前を使いますか? [y/N/edit] > " : "Use this name? [y/N/edit] > ");
5128
5295
  if (/^y/i.test(answer)) return {
5129
5296
  naming: final,
5130
5297
  intent: intent.trim()
5131
5298
  };
5132
5299
  if (/^e/i.test(answer)) {
5133
- const manual = await prompt("Enter feature/spec (e.g. tasks/create-and-complete) > ");
5300
+ const manual = await prompt(ja ? "feature/spec を入力 (例 tasks/create-and-complete) > " : "Enter feature/spec (e.g. tasks/create-and-complete) > ");
5134
5301
  const parts = manual.split("/");
5135
5302
  if (parts.length !== 2 || !parts[0] || !parts[1]) {
5136
5303
  error(`invalid spec path: "${manual}". Expected "<feature>/<spec>"`);
@@ -5503,7 +5670,7 @@ Return the spec keys that might be affected by any of the new files. Conservativ
5503
5670
  //#endregion
5504
5671
  //#region src/cli/drift.ts
5505
5672
  const DEFAULT_CONCURRENCY = 3;
5506
- const driftCommand = new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Check whether each spec.yaml is still in sync with the current codebase (CI-friendly, no patches applied).").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.").action(async (specPath, opts) => {
5673
+ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Check whether each spec.yaml is still in sync with the current codebase (CI-friendly, no patches applied).").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.")).action(async (specPath, opts) => {
5507
5674
  const format = parseFormat(opts.format);
5508
5675
  const threshold = parseSeverity(opts.severity);
5509
5676
  const concurrency = parseConcurrency(opts.concurrency);
@@ -5538,6 +5705,7 @@ const driftCommand = new Command("drift").argument("[feature/spec]", "Optional s
5538
5705
  blocks,
5539
5706
  concurrency,
5540
5707
  ...opts.model ? { model: opts.model } : {},
5708
+ ...opts.language ? { language: opts.language } : {},
5541
5709
  onSpecStart: (t) => {
5542
5710
  if (format === "text") info(`checking ${t.featureName}/${t.specName}`);
5543
5711
  }
@@ -5650,6 +5818,446 @@ function parseConcurrency(raw) {
5650
5818
  return n;
5651
5819
  }
5652
5820
  //#endregion
5821
+ //#region src/prompts/perspectives.ts
5822
+ /**
5823
+ * Build the system prompt. By default the descriptive fields follow the
5824
+ * spec's own language (Japanese specs → Japanese fields). An explicit
5825
+ * `--language` is applied by the CLI via `languageDirective`, appended to
5826
+ * this prompt, so the language handling lives in one shared place.
5827
+ */
5828
+ function buildPerspectivesSystemPrompt() {
5829
+ return `You produce a factual inventory of the E2E test coverage that already exists in a ccqa project.
5830
+
5831
+ 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.
5832
+
5833
+ ## Hard boundaries (do NOT cross)
5834
+
5835
+ - 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.
5836
+ - Do NOT do gap analysis. Do NOT list untested areas, missing coverage, or things the code has but the tests lack.
5837
+ - Do NOT evaluate whether the feature is good, complete, or correct.
5838
+ - Do NOT propose new test cases.
5839
+ - 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.
5840
+ - Do NOT touch status, relatedPaths, feature names, or spec names — the CLI already fixed those.
5841
+
5842
+ ## Fields to write (per spec)
5843
+
5844
+ - \`summary\`: 1–2 sentences, factual and neutral. What the test exercises and what it ultimately asserts, derived from the spec's \`steps\` (\`instruction\` / \`expected\`).
5845
+ - \`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.
5846
+ - \`testCondition\`: the state/precondition the scenario assumes, phrased as a condition (e.g. "Logged in as an admin", "Unauthenticated user"). Omit if none.
5847
+ - \`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.
5848
+
5849
+ ## How to write
5850
+
5851
+ - Same language as the spec's title (if titles are Japanese, write these fields in Japanese).
5852
+ - Keep each field short. These are index entries, not the test itself.
5853
+ - You may use Read/Grep/Glob sparingly to clarify domain vocabulary, but the steps are the primary source. Do not over-explore.
5854
+
5855
+ ## Output contract (STRICT)
5856
+
5857
+ Output exactly ONE fenced \`\`\`json code block, and nothing else outside it. No prose before or after.
5858
+
5859
+ Schema:
5860
+
5861
+ \`\`\`json
5862
+ {
5863
+ "summaries": [
5864
+ {
5865
+ "featureName": "<verbatim from input>",
5866
+ "specName": "<verbatim from input>",
5867
+ "summary": "<1–2 sentence factual description of what this test verifies>",
5868
+ "startScreen": "<opening screen/URL, or omit>",
5869
+ "testCondition": "<assumed state phrased as a condition, or omit>",
5870
+ "preconditions": ["<setup prerequisite>", "..."]
5871
+ }
5872
+ ]
5873
+ }
5874
+ \`\`\`
5875
+
5876
+ 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.
5877
+ `;
5878
+ }
5879
+ function buildPerspectivesPrompt(specs, instruction) {
5880
+ return `## Existing test cases to summarise
5881
+
5882
+ ${specs.map((s) => `### ${s.featureName}/${s.specName}
5883
+ title: ${s.title}
5884
+
5885
+ \`\`\`yaml
5886
+ ${s.specYaml.trimEnd()}
5887
+ \`\`\`
5888
+ `).join("\n")}
5889
+ ${instruction?.trim() ? `## Extra guidance from the user\n\n${instruction.trim()}\n\n` : ""}## Task
5890
+
5891
+ 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.
5892
+ `;
5893
+ }
5894
+ //#endregion
5895
+ //#region src/cli/perspectives.ts
5896
+ 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) => {
5897
+ await runPerspectives(opts);
5898
+ });
5899
+ async function runPerspectives(opts) {
5900
+ header("perspectives", ".ccqa/perspectives.yaml");
5901
+ await ensureCcqaDir();
5902
+ const skeleton = await buildSkeleton(await listFeatureTree());
5903
+ const allSpecs = skeleton.flatMap((f) => f.specs);
5904
+ if (allSpecs.length === 0) {
5905
+ info("no test cases found under .ccqa/features — nothing to inventory.");
5906
+ return;
5907
+ }
5908
+ const existingRaw = await tryReadPerspectives() ?? "";
5909
+ const noteMap = extractNotes(existingRaw);
5910
+ const specBodies = await loadSpecBodies(skeleton);
5911
+ meta("language", opts.language ?? "auto");
5912
+ info(`Summarising ${allSpecs.length} test case(s) across ${skeleton.length} feature(s)...`);
5913
+ const summaries = await requestSummaries(specBodies, opts);
5914
+ if (summaries === null) process.exit(1);
5915
+ const merged = mergePerspectives(skeleton, summaries, noteMap);
5916
+ let validated;
5917
+ try {
5918
+ validated = PerspectivesSchema.parse(merged);
5919
+ } catch (e) {
5920
+ error(`refused to write: assembled perspectives failed validation (${e.message})`);
5921
+ process.exit(1);
5922
+ }
5923
+ const next = stringify(validated, { lineWidth: 0 });
5924
+ if (withoutGeneratedAt(existingRaw) === withoutGeneratedAt(next)) {
5925
+ blank();
5926
+ info("perspectives already up to date — no changes.");
5927
+ return;
5928
+ }
5929
+ blank();
5930
+ info("--- proposed changes (perspectives.yaml) ---");
5931
+ printUnifiedDiff(existingRaw, next);
5932
+ blank();
5933
+ if (!(opts.apply === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "perspectives.yaml + .md を書き込みますか? [y/N] " : "Write perspectives.yaml + .md? [y/N] ")))) {
5934
+ info("aborted — no changes written.");
5935
+ return;
5936
+ }
5937
+ meta("saved", await savePerspectives(next));
5938
+ const labels = labelsFor(opts.language);
5939
+ meta("saved", await savePerspectivesMarkdown(renderIndexMarkdown(validated, labels)));
5940
+ for (const feature of validated.features) meta("saved", await saveFeaturePerspectivesMarkdown(feature.featureName, renderFeatureMarkdown(feature, labels)));
5941
+ }
5942
+ /**
5943
+ * Turn the feature tree into the skeleton perspectives features: title +
5944
+ * relatedPaths transcribed from each spec, status derived mechanically from
5945
+ * on-disk artifacts. `summary` is left empty here; Claude fills it later.
5946
+ * Specs whose spec.yaml is missing or unparsable are skipped.
5947
+ */
5948
+ async function buildSkeleton(tree) {
5949
+ return (await Promise.all(tree.map(async (feature) => {
5950
+ const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
5951
+ const spec = await readSpecMeta(feature.featureName, s.specName);
5952
+ const status = await deriveStatus(feature.featureName, s.specName);
5953
+ const entry = {
5954
+ specName: s.specName,
5955
+ title: spec.title,
5956
+ summary: "",
5957
+ status
5958
+ };
5959
+ if (s.relatedPaths) entry.relatedPaths = s.relatedPaths;
5960
+ return entry;
5961
+ }));
5962
+ return {
5963
+ featureName: feature.featureName,
5964
+ specs
5965
+ };
5966
+ }))).filter((f) => f.specs.length > 0).map((f) => ({
5967
+ featureName: f.featureName,
5968
+ specs: [...f.specs].sort((a, b) => a.specName.localeCompare(b.specName))
5969
+ })).sort((a, b) => a.featureName.localeCompare(b.featureName));
5970
+ }
5971
+ /**
5972
+ * `(featureName, specName)` → human note, parsed from an existing
5973
+ * perspectives.yaml. Notes are preserved across regeneration; everything
5974
+ * else (title, status, summary) is recomputed. Returns an empty map when the
5975
+ * input is empty or unparsable — note preservation is best-effort and never
5976
+ * blocks regeneration.
5977
+ */
5978
+ function extractNotes(existingRaw) {
5979
+ const map = /* @__PURE__ */ new Map();
5980
+ if (!existingRaw.trim()) return map;
5981
+ let parsed;
5982
+ try {
5983
+ parsed = parse(existingRaw);
5984
+ } catch {
5985
+ return map;
5986
+ }
5987
+ const result = PerspectivesSchema.safeParse(parsed);
5988
+ if (!result.success) return map;
5989
+ 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);
5990
+ return map;
5991
+ }
5992
+ /**
5993
+ * Merge the mechanical skeleton with Claude's summaries and the preserved
5994
+ * notes into the final perspectives object. Summaries are matched by
5995
+ * (featureName, specName); an unmatched spec keeps its empty summary.
5996
+ */
5997
+ function mergePerspectives(skeleton, summaries, noteMap) {
5998
+ const summaryMap = /* @__PURE__ */ new Map();
5999
+ for (const s of summaries) summaryMap.set(noteKey(s.featureName, s.specName), s);
6000
+ const features = skeleton.map((feature) => ({
6001
+ featureName: feature.featureName,
6002
+ specs: feature.specs.map((spec) => {
6003
+ const key = noteKey(feature.featureName, spec.specName);
6004
+ const entry = summaryMap.get(key);
6005
+ const merged = {
6006
+ ...spec,
6007
+ summary: entry?.summary ?? spec.summary
6008
+ };
6009
+ if (entry?.startScreen) merged.startScreen = entry.startScreen;
6010
+ if (entry?.testCondition) merged.testCondition = entry.testCondition;
6011
+ if (entry?.preconditions && entry.preconditions.length > 0) merged.preconditions = entry.preconditions;
6012
+ const note = noteMap.get(key);
6013
+ if (note !== void 0) merged.note = note;
6014
+ return merged;
6015
+ })
6016
+ }));
6017
+ return {
6018
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
6019
+ features
6020
+ };
6021
+ }
6022
+ /**
6023
+ * Strip the top-level `generatedAt:` line so two serialised perspectives can
6024
+ * be compared for substantive equality without the always-fresh timestamp
6025
+ * defeating the "already up to date" check. Exported for unit testing.
6026
+ */
6027
+ function withoutGeneratedAt(yamlText) {
6028
+ return yamlText.split("\n").filter((line) => !/^generatedAt:/.test(line)).join("\n").trim();
6029
+ }
6030
+ function noteKey(featureName, specName) {
6031
+ return `${featureName}/${specName}`;
6032
+ }
6033
+ async function readSpecMeta(featureName, specName) {
6034
+ const raw = await tryReadSpecFile(featureName, specName);
6035
+ if (raw === null) return { title: specName };
6036
+ try {
6037
+ const parsed = parse(raw);
6038
+ if (typeof parsed.title === "string" && parsed.title.length > 0) return { title: parsed.title };
6039
+ } catch {}
6040
+ return { title: specName };
6041
+ }
6042
+ async function deriveStatus(featureName, specName) {
6043
+ return {
6044
+ traced: await stat(join(getSpecDir(featureName, specName), "actions.json")).then(() => true).catch(() => false),
6045
+ generated: await getTestScript(featureName, specName) !== null
6046
+ };
6047
+ }
6048
+ async function loadSpecBodies(skeleton) {
6049
+ return await Promise.all(skeleton.flatMap((feature) => feature.specs.map(async (spec) => {
6050
+ const specYaml = await tryReadSpecFile(feature.featureName, spec.specName) ?? "";
6051
+ return {
6052
+ featureName: feature.featureName,
6053
+ specName: spec.specName,
6054
+ title: spec.title,
6055
+ specYaml
6056
+ };
6057
+ })));
6058
+ }
6059
+ async function requestSummaries(specs, opts) {
6060
+ const toolCounts = {};
6061
+ const startedAt = Date.now();
6062
+ const { result, isError } = await invokeClaudeStreaming({
6063
+ prompt: buildPerspectivesPrompt(specs, opts.instruction),
6064
+ systemPrompt: buildPerspectivesSystemPrompt() + languageDirective(opts.language),
6065
+ allowedTools: [
6066
+ "Read",
6067
+ "Grep",
6068
+ "Glob"
6069
+ ],
6070
+ silenceBashLog: true,
6071
+ ...opts.model ? { model: opts.model } : {}
6072
+ }, (msg) => {
6073
+ if (msg.type !== "assistant") return;
6074
+ for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
6075
+ });
6076
+ process.stdout.write(`${formatToolSummary(toolCounts, Date.now() - startedAt)}\n`);
6077
+ if (isError) {
6078
+ error("Claude returned an error result");
6079
+ return null;
6080
+ }
6081
+ const json = extractJsonBlock(result);
6082
+ if (!json) {
6083
+ error("Claude did not return a json block");
6084
+ return null;
6085
+ }
6086
+ return parseSummaries(json);
6087
+ }
6088
+ /**
6089
+ * Parse the `{ summaries: [...] }` JSON contract into typed entries. Returns
6090
+ * null and logs when the payload is malformed. Exported for unit testing.
6091
+ */
6092
+ function parseSummaries(json) {
6093
+ let payload;
6094
+ try {
6095
+ payload = JSON.parse(json);
6096
+ } catch (e) {
6097
+ error(`failed to parse summaries JSON: ${e.message}`);
6098
+ return null;
6099
+ }
6100
+ if (typeof payload !== "object" || payload === null) {
6101
+ error("summaries payload is not an object");
6102
+ return null;
6103
+ }
6104
+ const summaries = payload.summaries;
6105
+ if (!Array.isArray(summaries)) {
6106
+ error("summaries payload missing a `summaries` array");
6107
+ return null;
6108
+ }
6109
+ const out = [];
6110
+ for (const item of summaries) {
6111
+ const rec = item ?? {};
6112
+ const { featureName, specName, summary } = rec;
6113
+ if (typeof featureName === "string" && typeof specName === "string" && typeof summary === "string") {
6114
+ const entry = {
6115
+ featureName,
6116
+ specName,
6117
+ summary
6118
+ };
6119
+ if (typeof rec.startScreen === "string" && rec.startScreen.length > 0) entry.startScreen = rec.startScreen;
6120
+ if (typeof rec.testCondition === "string" && rec.testCondition.length > 0) entry.testCondition = rec.testCondition;
6121
+ if (Array.isArray(rec.preconditions)) {
6122
+ const pre = rec.preconditions.filter((p) => typeof p === "string" && p.length > 0);
6123
+ if (pre.length > 0) entry.preconditions = pre;
6124
+ }
6125
+ out.push(entry);
6126
+ }
6127
+ }
6128
+ return out;
6129
+ }
6130
+ const LABELS_JA = {
6131
+ indexTitle: "テスト観点インデックス (perspectives)",
6132
+ caseCol: "ケース",
6133
+ itemCol: "項目",
6134
+ valueCol: "内容",
6135
+ summary: "検証内容",
6136
+ preconditions: "前提条件",
6137
+ startScreen: "開始画面",
6138
+ relatedCode: "関連コード"
6139
+ };
6140
+ const LABELS_EN = {
6141
+ indexTitle: "Test Perspectives (perspectives)",
6142
+ caseCol: "Case",
6143
+ itemCol: "Item",
6144
+ valueCol: "Value",
6145
+ summary: "Verifies",
6146
+ preconditions: "Preconditions",
6147
+ startScreen: "Start screen",
6148
+ relatedCode: "Related code"
6149
+ };
6150
+ /**
6151
+ * Pick the label set for a `--language` value. Only an explicit English tag
6152
+ * (`en`, `en-US`, …) switches to English labels; `auto`, `ja`, and anything
6153
+ * else keep Japanese, matching the source-following default the rest of the
6154
+ * command uses.
6155
+ */
6156
+ function labelsFor(language) {
6157
+ return /^en\b/i.test(language?.trim() ?? "") ? LABELS_EN : LABELS_JA;
6158
+ }
6159
+ /**
6160
+ * Path to a spec.yaml relative to the **root** `.ccqa/perspectives.md`
6161
+ * (i.e. relative to the `.ccqa/` dir). Used for the category index links.
6162
+ */
6163
+ function specRelPathFromRoot(featureName, specName) {
6164
+ return `features/${featureName}/test-cases/${specName}/spec.yaml`;
6165
+ }
6166
+ /**
6167
+ * Path to a category detail file relative to the **root** `.ccqa/perspectives.md`.
6168
+ * The detail file is written to `.ccqa/features/<feature>/perspectives.md`
6169
+ * (see `getFeaturePerspectivesMarkdownPath`), so the link must include the
6170
+ * `features/` segment — otherwise the category heading link 404s.
6171
+ */
6172
+ function featureDetailRelPathFromRoot(featureName) {
6173
+ return `features/${featureName}/perspectives.md`;
6174
+ }
6175
+ /**
6176
+ * Path to a spec.yaml relative to the **category** detail file
6177
+ * `.ccqa/features/<feature>/perspectives.md`. The spec lives alongside under
6178
+ * `test-cases/<spec>/`, so the category file links to it directly — which is
6179
+ * what makes the link resolve both on GitHub and in a local editor.
6180
+ */
6181
+ function specRelPathFromCategory(specName) {
6182
+ return `test-cases/${specName}/spec.yaml`;
6183
+ }
6184
+ /**
6185
+ * Render the root `.ccqa/perspectives.md`: a category-grouped index of which
6186
+ * cases exist. Each feature is a heading (linking to its own detail
6187
+ * `perspectives.md`) followed by a row per case — title, status, and a link
6188
+ * to that case's spec.yaml. The per-case *detail* (検証内容, preconditions,
6189
+ * note) still lives only in the per-category file; the root stays a scannable
6190
+ * "what is tested, and where" overview.
6191
+ *
6192
+ * Pure and deterministic, so the index rendering is easy to unit-test.
6193
+ */
6194
+ function renderIndexMarkdown(perspectives, labels = LABELS_JA) {
6195
+ const lines = [];
6196
+ lines.push(`# ${labels.indexTitle}`);
6197
+ lines.push("");
6198
+ for (const feature of perspectives.features) {
6199
+ const detailLink = featureDetailRelPathFromRoot(feature.featureName);
6200
+ lines.push(`## [${feature.featureName}](${detailLink})`);
6201
+ lines.push("");
6202
+ lines.push(`| ${labels.caseCol} | spec |`);
6203
+ lines.push("| --- | --- |");
6204
+ for (const spec of feature.specs) {
6205
+ const specLink = specRelPathFromRoot(feature.featureName, spec.specName);
6206
+ lines.push(`| ${mdCell(spec.title)} | [spec](${specLink}) |`);
6207
+ }
6208
+ lines.push("");
6209
+ }
6210
+ return lines.join("\n");
6211
+ }
6212
+ /**
6213
+ * Render one category's `.ccqa/features/<feature>/perspectives.md`: every
6214
+ * case in the category as a self-contained vertical table. All columns —
6215
+ * including the verification summary (検証内容) and the human note — live
6216
+ * inside the table; nothing is emitted outside it. Detailed steps / expected
6217
+ * results are still not restated (the spec.yaml is their single home); the
6218
+ * table links back to each spec instead.
6219
+ *
6220
+ * Pure and deterministic, so the per-case rendering is easy to unit-test.
6221
+ */
6222
+ function renderFeatureMarkdown(feature, labels = LABELS_JA) {
6223
+ const lines = [];
6224
+ lines.push(`# ${feature.featureName}`);
6225
+ lines.push("");
6226
+ for (const spec of feature.specs) lines.push(...renderSpecMarkdown(spec, labels));
6227
+ return lines.join("\n");
6228
+ }
6229
+ /**
6230
+ * Render one spec as a single vertical (item | content) Markdown table for a
6231
+ * category file. Verification summary and preconditions lead. The spec link
6232
+ * is relative to this category file so it resolves both on GitHub and in a
6233
+ * local editor. Related-code paths stay inline code rather than links: their
6234
+ * base (the cwd that hosts `.ccqa/`) is not reliably recoverable here — specs
6235
+ * carry a mix of cwd-relative (`src/...`) and repo-root (`pkg/app/src/...`)
6236
+ * forms — and many are globs that no link could open anyway. 検証内容
6237
+ * (summary) and note are rows inside the table; no prose blocks are emitted
6238
+ * around it. Exported for focused unit testing.
6239
+ */
6240
+ function renderSpecMarkdown(spec, labels = LABELS_JA) {
6241
+ const lines = [];
6242
+ lines.push(`## ${spec.title}`);
6243
+ lines.push("");
6244
+ lines.push(`| ${labels.itemCol} | ${labels.valueCol} |`);
6245
+ lines.push("| --- | --- |");
6246
+ if (spec.summary) lines.push(`| ${labels.summary} | ${mdCell(spec.summary)} |`);
6247
+ if (spec.preconditions && spec.preconditions.length > 0) lines.push(`| ${labels.preconditions} | ${spec.preconditions.map(mdCell).join("<br>")} |`);
6248
+ if (spec.startScreen) lines.push(`| ${labels.startScreen} | ${mdCell(spec.startScreen)} |`);
6249
+ const specPath = specRelPathFromCategory(spec.specName);
6250
+ lines.push(`| spec | [${specPath}](${specPath}) |`);
6251
+ if (spec.relatedPaths && spec.relatedPaths.length > 0) lines.push(`| ${labels.relatedCode} | ${spec.relatedPaths.map((p) => `\`${p}\``).join("<br>")} |`);
6252
+ if (spec.note) lines.push(`| 📝 note | ${mdCell(spec.note)} |`);
6253
+ lines.push("");
6254
+ return lines;
6255
+ }
6256
+ /** Escape pipes / newlines so a value stays inside one Markdown table cell. */
6257
+ function mdCell(value) {
6258
+ return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
6259
+ }
6260
+ //#endregion
5653
6261
  //#region src/cli/index.ts
5654
6262
  const packageJsonPath = resolvePackageJson();
5655
6263
  const { version } = JSON.parse(readFileSync(packageJsonPath, "utf8"));
@@ -5667,6 +6275,7 @@ const program = new Command();
5667
6275
  program.name("ccqa").description("E2E test CLI using Claude Code + agent-browser").version(version);
5668
6276
  program.addCommand(draftCommand);
5669
6277
  program.addCommand(driftCommand);
6278
+ program.addCommand(perspectivesCommand);
5670
6279
  program.addCommand(traceCommand);
5671
6280
  program.addCommand(generateCommand);
5672
6281
  program.addCommand(runCommand);
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {