diff --git a/.capabilities/core/discovery/discover-project-capability-candidates.capability.yaml b/.capabilities/core/discovery/discover-project-capability-candidates.capability.yaml index ad567d3..43b9a9d 100644 --- a/.capabilities/core/discovery/discover-project-capability-candidates.capability.yaml +++ b/.capabilities/core/discovery/discover-project-capability-candidates.capability.yaml @@ -1,5 +1,5 @@ title: Discover project capability candidates -status: planned +status: implemented summary: Add a CLI command that asks a coding agent to investigate an installed project's full codebase and propose the first draft of what the app currently does. @@ -67,6 +67,11 @@ agent: - core/project/initialize-capability-project - core/model/define-capability-format - core/agents/run-external-agent-cli + implementation: + references: + - packages/core/src/discovery.ts + - packages/core/tests/discovery.test.ts + - packages/cli/src/index.ts verification: automated: - id: discovery-report-tests @@ -84,62 +89,3 @@ agent: - Initial agent discovery may miss behavior that is only visible through runtime state, production configuration, external services, or code paths outside the inspected scope. - review: - depth: partial - source: coding-agent - gaps: - - Provides a CLI command that runs from the target project root. - - Invokes or prepares a handoff for the user's selected coding agent to - inspect source code, tests, routes, handlers, UI flows, data models, - scripts, configuration, and documentation. - - Makes README files, package metadata, and docs supporting context, not - the sole or primary basis for capability discovery. - - Requires the agent to propose candidate capability titles, likely areas, - source evidence, and confidence notes from concrete code discovery. - - Produces a structured discovery report that records what files and - project areas the agent inspected. - - Does not create or overwrite capability files unless the user explicitly - requests output generation. - - Labels low-confidence candidates, shallow inspection, and missing code - evidence as review gaps. - intent_summary: Discover project capability candidates is currently planned. I - found no implementation references to inspect, so the acceptance criteria - are not implemented yet. - criteria: - - criterion: Provides a CLI command that runs from the target project root. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Invokes or prepares a handoff for the user's selected coding agent to - inspect source code, tests, routes, handlers, UI flows, data models, - scripts, configuration, and documentation. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Makes README files, package metadata, and docs supporting context, - not the sole or primary basis for capability discovery. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Requires the agent to propose candidate capability titles, likely - areas, source evidence, and confidence notes from concrete code - discovery. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Produces a structured discovery report that records what files and - project areas the agent inspected. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Does not create or overwrite capability files unless the user - explicitly requests output generation. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Labels low-confidence candidates, shallow inspection, and missing - code evidence as review gaps. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - done: false diff --git a/.capabilities/core/discovery/organize-discovered-capability-map.capability.yaml b/.capabilities/core/discovery/organize-discovered-capability-map.capability.yaml index baedde7..7a8321f 100644 --- a/.capabilities/core/discovery/organize-discovered-capability-map.capability.yaml +++ b/.capabilities/core/discovery/organize-discovered-capability-map.capability.yaml @@ -1,5 +1,5 @@ title: Organize discovered capability map -status: planned +status: implemented summary: Create a readable folder and dependency structure from agent-discovered capabilities so users can browse what the app currently does. intent: Make the generated `.capabilities/` tree feel like a product map rather @@ -50,6 +50,10 @@ agent: - core/discovery/generate-draft-capability-files - core/graph/compile-capabilities - core/graph/analyze-capability-impact + implementation: + references: + - packages/core/src/discovery.ts + - packages/core/tests/discoveryOrganization.test.ts verification: automated: - id: capability-map-organization-tests @@ -65,45 +69,3 @@ agent: gaps: - Dependency inference may need project-specific review for cross-cutting infrastructure and shared services. - review: - depth: partial - source: coding-agent - gaps: - - Groups agent-generated capabilities into meaningful area and subarea - folders. - - Suggests capability IDs and filenames that are stable, readable, and - aligned with the generated folder structure. - - Detects likely dependencies between discovered capabilities when code - evidence or workflow relationships support them. - - Provides a summary index of generated areas and top-level capabilities. - - Flags ambiguous grouping decisions for human review instead of hiding - them. - intent_summary: Organize discovered capability map is currently planned. I found - no implementation references to inspect, so the acceptance criteria are - not implemented yet. - criteria: - - criterion: Groups agent-generated capabilities into meaningful area and subarea - folders. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Suggests capability IDs and filenames that are stable, readable, and - aligned with the generated folder structure. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Detects likely dependencies between discovered capabilities when code - evidence or workflow relationships support them. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Provides a summary index of generated areas and top-level capabilities. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - - criterion: Flags ambiguous grouping decisions for human review instead of hiding - them. - status: uncovered - notes: Capability status is planned and no agent.implementation.references are - declared. - done: false diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3ee69a0..b8ed316 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,7 @@ import { analyzeCapabilityImpact, adviseImplementationCoverage, assessImplementationCoverage, + buildCapabilityDiscoveryPrompt, buildAgentReviewPrompt, buildAgentTaskBundle, compileCapabilities, @@ -17,18 +18,20 @@ import { formatAssessmentAdviceReport, formatImplementationCoverageReport, loadCapabilities, + organizeDiscoveredCapabilityMap, runExternalAgentCommand, saveAgentReviewResult, summarizeSavedReviewHealth, summarizeCapabilityStatus, syncReviewEvidence, validateAgentReviewResult, + validateDiscoveryReport, validateLoadedCapabilities, writeCompiledCapabilities, formatSyncReviewEvidenceReport, formatCapabilities } from "@capabilitykit/core"; -import type { Capability, LoadCapabilitiesResult, VerificationGap } from "@capabilitykit/core"; +import type { Capability, CapabilityDiscoveryReport, LoadCapabilitiesResult, VerificationGap } from "@capabilitykit/core"; import { installCapabilityKitSkill } from "./skillInstall.js"; import { filterStatusReportByRelease, formatStoryMapStatusReport, formatStoryMapViewerHtml } from "./statusOutput.js"; @@ -142,6 +145,65 @@ function collectOption(value: string, previous: string[] = []): string[] { return [...previous, value]; } +function extractJsonObject(value: string): string { + const trimmed = value.trim(); + if (trimmed.startsWith("{")) { + return trimmed; + } + + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced?.[1]?.trim().startsWith("{")) { + return fenced[1].trim(); + } + + const first = trimmed.indexOf("{"); + const last = trimmed.lastIndexOf("}"); + if (first >= 0 && last > first) { + return trimmed.slice(first, last + 1); + } + + throw new Error("Could not find a JSON discovery report in agent output."); +} + +function parseDiscoveryReport(value: string): CapabilityDiscoveryReport { + const parsed = JSON.parse(extractJsonObject(value)) as CapabilityDiscoveryReport; + return parsed; +} + +function formatDiscoveryValidation(report: CapabilityDiscoveryReport): string { + const validation = validateDiscoveryReport(report); + const organized = organizeDiscoveredCapabilityMap(report); + const lines = [ + "CapabilityKit discovery report", + "", + `Candidates: ${report.candidates?.length ?? 0}`, + `Inspected files: ${report.inspected_files?.length ?? 0}`, + `Inspected areas: ${report.inspected_areas?.length ?? 0}`, + `Validation: ${validation.valid ? "valid" : "invalid"}`, + organized.summary + ]; + + if (validation.issues.length > 0) { + lines.push("", "Issues:", ...validation.issues.map((issue) => ` - ${issue.message}`)); + } + + if (validation.gaps.length > 0) { + lines.push("", "Review gaps:", ...validation.gaps.map((gap) => ` - ${gap.message}`)); + } + + if (organized.areas.length > 0) { + lines.push("", "Suggested structure:"); + for (const area of organized.areas) { + lines.push(` ${area.area}`); + for (const capability of area.capabilities) { + lines.push(` - ${capability.id} -> ${capability.filePath}`); + } + } + } + + return `${lines.join("\n")}\n`; +} + type AdviceReport = Awaited>; function noisyScore(capability: AdviceReport["capabilities"][number]): number { @@ -1627,6 +1689,7 @@ Common workflows: capabilitykit check Run the cheap daily health check capabilitykit check --fix Format capabilities and refresh compiled output capabilitykit next Show the next most useful maintenance actions + capabilitykit discover Prepare a coding-agent discovery prompt capabilitykit verify Save deterministic implementation review evidence capabilitykit verify --agent codex Run an opt-in semantic review with an external agent @@ -1634,6 +1697,7 @@ Common workflows: Command groups: Setup: init, create, skill Daily: check, next, format, validate, compile, status + Discovery: discover Review: verify, assess, advise, review, sync-review, review-noisy Agent: agent-task, agent-run, agent-review, review-result Visualize: graph, graph-viewer, story-map-viewer @@ -1715,6 +1779,101 @@ program console.log(" Ask Codex: review this capability against its agent.implementation.references"); }); + +program + .command("discover") + .description("Ask or prepare a coding agent to discover capability candidates from this codebase") + .option("--command ", "external agent executable to run") + .option("--arg ", "argument to pass to the external agent command; repeat for multiple args", collectOption, []) + .option("--handoff ", "agent handoff strategy: stdin, argument, or prompt-file", "stdin") + .option("--prompt-file ", "prompt file path for prompt-file handoff") + .option("--output-prompt ", "write the discovery prompt to a file without running an agent") + .option("--report ", "write the parsed discovery report JSON to a file") + .option("--transcript ", "write stdout, stderr, exit code, and handoff details to a transcript file") + .option("--dry-run", "detect the command and prepare handoff files without running the external agent") + .option("--json", "print the parsed report and organized suggestions as JSON") + .action( + async (options: { + command?: string; + arg: string[]; + handoff: string; + promptFile?: string; + outputPrompt?: string; + report?: string; + transcript?: string; + dryRun?: boolean; + json?: boolean; + }) => { + const prompt = buildCapabilityDiscoveryPrompt(process.cwd()); + + if (options.outputPrompt) { + const outputPath = path.resolve(process.cwd(), options.outputPrompt); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, prompt); + console.log(`Wrote ${path.relative(process.cwd(), outputPath)}`); + return; + } + + if (!options.command) { + console.log(prompt); + return; + } + + const result = await runExternalAgentCommand({ + command: options.command, + args: options.arg, + cwd: process.cwd(), + input: prompt, + handoff: parseAgentHandoff(options.handoff), + promptFilePath: options.promptFile, + transcriptPath: options.transcript, + dryRun: options.dryRun + }); + + console.log(`Command: ${[result.command, ...result.args].join(" ")}`); + console.log(`Handoff: ${result.handoff}`); + if (result.promptFilePath) { + console.log(`Prompt file: ${path.relative(process.cwd(), result.promptFilePath)}`); + } + if (result.transcriptPath) { + console.log(`Transcript: ${path.relative(process.cwd(), result.transcriptPath)}`); + } + if (result.dryRun) { + console.log("Result: dry run"); + return; + } + console.log(`Exit code: ${result.exitCode ?? "unknown"}`); + + if (result.exitCode !== 0) { + if (result.stdout.trim()) console.log(result.stdout.trimEnd()); + if (result.stderr.trim()) console.error(result.stderr.trimEnd()); + process.exitCode = result.exitCode ?? 1; + return; + } + + const report = parseDiscoveryReport(result.stdout); + const validation = validateDiscoveryReport(report); + const organized = organizeDiscoveredCapabilityMap(report); + + if (options.report) { + const reportPath = path.resolve(process.cwd(), options.report); + await fs.mkdir(path.dirname(reportPath), { recursive: true }); + await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + } + + if (options.json) { + console.log(JSON.stringify({ report, validation, organized }, null, 2)); + } else { + console.log(""); + console.log(formatDiscoveryValidation(report).trimEnd()); + } + + if (!validation.valid) { + process.exitCode = 1; + } + } + ); + program .command("format") .description("Format capability files into canonical section order and refresh agent section comments") diff --git a/packages/core/src/discovery.ts b/packages/core/src/discovery.ts new file mode 100644 index 0000000..00f7988 --- /dev/null +++ b/packages/core/src/discovery.ts @@ -0,0 +1,335 @@ +import path from "node:path"; + +export type DiscoveryConfidence = "high" | "medium" | "low"; + +export interface DiscoverySourceEvidence { + path: string; + kind?: "source" | "test" | "route" | "ui" | "model" | "script" | "config" | "doc" | "unknown"; + notes?: string; +} + +export interface DiscoveryCapabilityCandidate { + title: string; + summary?: string; + intent?: string; + likely_area?: string; + acceptance?: string[]; + source_evidence: DiscoverySourceEvidence[]; + confidence: DiscoveryConfidence; + confidence_notes?: string; + review_gaps?: string[]; + depends_on?: string[]; +} + +export interface CapabilityDiscoveryReport { + generated_at?: string; + project_root?: string; + inspected_files: string[]; + inspected_areas: string[]; + candidates: DiscoveryCapabilityCandidate[]; + review_gaps?: string[]; +} + +export interface DiscoveryReportIssue { + code: string; + message: string; + candidate?: string; +} + +export interface DiscoveryReportValidationResult { + valid: boolean; + issues: DiscoveryReportIssue[]; + gaps: DiscoveryReportIssue[]; +} + +export interface OrganizedCapabilitySuggestion { + title: string; + area: string; + id: string; + filePath: string; + evidence: DiscoverySourceEvidence[]; + depends_on: string[]; + review_gaps: string[]; +} + +export interface OrganizedCapabilityArea { + area: string; + capabilities: OrganizedCapabilitySuggestion[]; +} + +export interface OrganizedCapabilityMap { + areas: OrganizedCapabilityArea[]; + capabilities: OrganizedCapabilitySuggestion[]; + dependency_suggestions: Array<{ from: string; to: string; reason: string }>; + review_gaps: string[]; + summary: string; +} + +const DOCUMENTATION_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".rst", ".adoc"]); +const SOURCE_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".py", + ".rb", + ".go", + ".rs", + ".java", + ".kt", + ".swift", + ".php", + ".cs", + ".html", + ".css", + ".scss", + ".vue", + ".svelte" +]); + +export function slugifyDiscoverySegment(value: string): string { + const slug = value + .trim() + .toLowerCase() + .replace(/['’]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "general"; +} + +function normalizeArea(area: string | undefined): string { + if (!area?.trim()) { + return "general"; + } + + return area + .split(/[\\/]+/) + .map((segment) => slugifyDiscoverySegment(segment)) + .filter(Boolean) + .slice(0, 3) + .join("/") || "general"; +} + +function evidenceKind(evidence: DiscoverySourceEvidence): string { + if (evidence.kind && evidence.kind !== "unknown") { + return evidence.kind; + } + + const ext = path.extname(evidence.path).toLowerCase(); + if (DOCUMENTATION_EXTENSIONS.has(ext)) return "doc"; + if (SOURCE_EXTENSIONS.has(ext)) return "source"; + if ([".json", ".yaml", ".yml", ".toml"].includes(ext)) return "config"; + return "unknown"; +} + +function hasConcreteCodeEvidence(candidate: DiscoveryCapabilityCandidate): boolean { + return candidate.source_evidence.some((evidence) => { + const kind = evidenceKind(evidence); + return kind !== "doc" && kind !== "unknown" && kind !== "config"; + }); +} + +function hasDocumentationOnlyEvidence(candidate: DiscoveryCapabilityCandidate): boolean { + return candidate.source_evidence.length > 0 && candidate.source_evidence.every((evidence) => evidenceKind(evidence) === "doc"); +} + +export function validateDiscoveryReport(report: CapabilityDiscoveryReport): DiscoveryReportValidationResult { + const issues: DiscoveryReportIssue[] = []; + const gaps: DiscoveryReportIssue[] = []; + + if (!Array.isArray(report.inspected_files) || report.inspected_files.length === 0) { + gaps.push({ code: "shallow-inspection", message: "Discovery report does not list inspected files." }); + } + + if (!Array.isArray(report.inspected_areas) || report.inspected_areas.length === 0) { + gaps.push({ code: "shallow-inspection", message: "Discovery report does not summarize inspected project areas." }); + } + + if (!Array.isArray(report.candidates) || report.candidates.length === 0) { + issues.push({ code: "missing-candidates", message: "Discovery report must include at least one capability candidate." }); + } + + for (const candidate of report.candidates ?? []) { + const label = candidate.title || "Untitled candidate"; + if (!candidate.title?.trim()) { + issues.push({ code: "missing-title", message: "Capability candidate is missing a title.", candidate: label }); + } + if (!candidate.likely_area?.trim()) { + gaps.push({ code: "missing-area", message: `Candidate "${label}" does not include a likely area.`, candidate: label }); + } + if (!Array.isArray(candidate.source_evidence) || candidate.source_evidence.length === 0) { + issues.push({ code: "missing-evidence", message: `Candidate "${label}" does not include source evidence.`, candidate: label }); + continue; + } + if (!hasConcreteCodeEvidence(candidate)) { + gaps.push({ code: "missing-code-evidence", message: `Candidate "${label}" lacks concrete implementation evidence.`, candidate: label }); + } + if (hasDocumentationOnlyEvidence(candidate)) { + gaps.push({ code: "documentation-only-evidence", message: `Candidate "${label}" is based only on documentation evidence.`, candidate: label }); + } + if (candidate.confidence === "low") { + gaps.push({ code: "low-confidence", message: `Candidate "${label}" is marked low confidence.`, candidate: label }); + } + } + + return { + valid: issues.length === 0, + issues, + gaps + }; +} + +export function buildCapabilityDiscoveryPrompt(projectRoot = "."): string { + return [ + "# CapabilityKit Discovery", + "", + `Project root: ${projectRoot}`, + "", + "Inspect the installed project's source code and propose candidate CapabilityKit capabilities for what the app currently does.", + "", + "## Inspection requirements", + "", + "- Inspect source code, tests, routes, handlers, UI flows, data models, scripts, configuration, and documentation where present.", + "- Treat README files, package metadata, and docs as supporting context only; do not base candidates solely on them.", + "- Prefer small groups of user-visible behavior over lists of files or internal components.", + "- Label shallow inspection, missing code evidence, and low-confidence candidates as review gaps.", + "- Do not create or overwrite `.capability.yaml` files.", + "", + "## Output", + "", + "Return only JSON with this shape:", + "", + "```json", + JSON.stringify( + { + generated_at: "ISO-8601 timestamp", + project_root: projectRoot, + inspected_files: ["src/example.ts"], + inspected_areas: ["routes", "tests"], + candidates: [ + { + title: "User-visible capability title", + likely_area: "product/domain", + summary: "One-sentence behavior summary.", + intent: "Why the behavior exists.", + acceptance: ["Concrete behavior criterion."], + source_evidence: [{ path: "src/example.ts", kind: "source", notes: "What this file proves." }], + confidence: "medium", + confidence_notes: "Why confidence is not higher.", + review_gaps: ["What a human should verify."], + depends_on: [] + } + ], + review_gaps: [] + }, + null, + 2 + ), + "```" + ].join("\n"); +} + +function stableCandidateId(candidate: DiscoveryCapabilityCandidate, used: Set): string { + const area = normalizeArea(candidate.likely_area); + const base = `${area}/${slugifyDiscoverySegment(candidate.title)}`; + let id = base; + let suffix = 2; + while (used.has(id)) { + id = `${base}-${suffix}`; + suffix += 1; + } + used.add(id); + return id; +} + +function dependencyReason(from: DiscoveryCapabilityCandidate, to: DiscoveryCapabilityCandidate): string | undefined { + if (from.depends_on?.includes(to.title)) { + return `Candidate explicitly listed "${to.title}" as a dependency.`; + } + const fromEvidence = new Set(from.source_evidence.map((evidence) => evidence.path)); + const shared = to.source_evidence.find((evidence) => fromEvidence.has(evidence.path)); + if (shared && normalizeArea(from.likely_area) !== normalizeArea(to.likely_area)) { + return `Both candidates cite ${shared.path}, suggesting a conservative cross-area workflow relationship.`; + } + return undefined; +} + +export function organizeDiscoveredCapabilityMap(report: CapabilityDiscoveryReport): OrganizedCapabilityMap { + const validation = validateDiscoveryReport(report); + const usedIds = new Set(); + const titleToId = new Map(); + const capabilities: OrganizedCapabilitySuggestion[] = report.candidates.map((candidate) => { + const id = stableCandidateId(candidate, usedIds); + titleToId.set(candidate.title, id); + const area = normalizeArea(candidate.likely_area); + const review_gaps = [...(candidate.review_gaps ?? [])]; + if (!candidate.likely_area?.trim()) review_gaps.push("Area was inferred as general and needs human review."); + if (!hasConcreteCodeEvidence(candidate)) review_gaps.push("Candidate lacks concrete implementation evidence."); + if (candidate.confidence === "low") review_gaps.push("Candidate was marked low confidence by discovery."); + return { + title: candidate.title, + area, + id, + filePath: `.capabilities/${id}.capability.yaml`, + evidence: candidate.source_evidence, + depends_on: [] as string[], + review_gaps + }; + }); + + const byTitle = new Map(report.candidates.map((candidate) => [candidate.title, candidate])); + const byId = new Map(capabilities.map((capability) => [capability.id, capability])); + const dependency_suggestions: OrganizedCapabilityMap["dependency_suggestions"] = []; + + for (const fromCandidate of report.candidates) { + const fromId = titleToId.get(fromCandidate.title); + if (!fromId) continue; + const fromSuggestion = byId.get(fromId); + for (const toCandidate of report.candidates) { + if (fromCandidate === toCandidate) continue; + const toId = titleToId.get(toCandidate.title); + if (!toId) continue; + const reason = dependencyReason(fromCandidate, toCandidate); + if (!reason) continue; + if (!fromSuggestion?.depends_on.includes(toId)) { + fromSuggestion?.depends_on.push(toId); + dependency_suggestions.push({ from: fromId, to: toId, reason }); + } + } + for (const dependencyTitle of fromCandidate.depends_on ?? []) { + const target = byTitle.get(dependencyTitle); + const toId = target ? titleToId.get(target.title) : undefined; + if (toId && !fromSuggestion?.depends_on.includes(toId)) { + fromSuggestion?.depends_on.push(toId); + dependency_suggestions.push({ from: fromId, to: toId, reason: `Candidate explicitly listed "${dependencyTitle}" as a dependency.` }); + } + } + } + + const areaMap = new Map(); + for (const capability of capabilities) { + areaMap.set(capability.area, [...(areaMap.get(capability.area) ?? []), capability]); + } + const areas = [...areaMap.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([area, areaCapabilities]) => ({ + area, + capabilities: areaCapabilities.sort((a, b) => a.id.localeCompare(b.id)) + })); + + const review_gaps = [ + ...(report.review_gaps ?? []), + ...validation.gaps.map((gap) => gap.message), + ...capabilities.flatMap((capability) => capability.review_gaps.map((gap) => `${capability.id}: ${gap}`)) + ]; + + return { + areas, + capabilities: capabilities.sort((a, b) => a.id.localeCompare(b.id)), + dependency_suggestions, + review_gaps: [...new Set(review_gaps)], + summary: `${capabilities.length} discovered capabilities organized into ${areas.length} areas: ${areas.map((area) => area.area).join(", ") || "none"}.` + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 47101d3..2bdb1c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,12 @@ export { loadCapabilities } from "./loadCapabilities.js"; export { parseCapability } from "./parseCapability.js"; export { capabilitySchema, projectConfigSchema } from "./schema.js"; export { formatSyncReviewEvidenceReport, syncReviewEvidence } from "./syncReviewEvidence.js"; +export { + buildCapabilityDiscoveryPrompt, + organizeDiscoveredCapabilityMap, + slugifyDiscoverySegment, + validateDiscoveryReport +} from "./discovery.js"; export { validateLoadedCapabilities } from "./validateCapabilities.js"; export type { AgentTaskBundle, AgentTaskMode, AgentTaskOptions } from "./agentTask.js"; export type { @@ -79,3 +85,15 @@ export type { VerificationGapIgnore, VerificationGap } from "./types.js"; + +export type { + CapabilityDiscoveryReport, + DiscoveryCapabilityCandidate, + DiscoveryConfidence, + DiscoveryReportIssue, + DiscoveryReportValidationResult, + DiscoverySourceEvidence, + OrganizedCapabilityArea, + OrganizedCapabilityMap, + OrganizedCapabilitySuggestion +} from "./discovery.js"; diff --git a/packages/core/tests/discovery.test.ts b/packages/core/tests/discovery.test.ts new file mode 100644 index 0000000..0596007 --- /dev/null +++ b/packages/core/tests/discovery.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { buildCapabilityDiscoveryPrompt, validateDiscoveryReport } from "../src/discovery.js"; + +const baseReport = { + inspected_files: ["src/routes/login.ts", "src/routes/login.test.ts", "README.md"], + inspected_areas: ["routes", "tests", "docs"], + candidates: [ + { + title: "Authenticate users", + likely_area: "accounts/authentication", + source_evidence: [ + { path: "src/routes/login.ts", kind: "route" as const, notes: "Handles login requests." }, + { path: "src/routes/login.test.ts", kind: "test" as const, notes: "Covers successful login." } + ], + confidence: "high" as const + } + ] +}; + +describe("validateDiscoveryReport", () => { + it("accepts candidates with inspected files, areas, and concrete code evidence", () => { + const result = validateDiscoveryReport(baseReport); + + expect(result.valid).toBe(true); + expect(result.issues).toEqual([]); + expect(result.gaps).toEqual([]); + }); + + it("flags documentation-only evidence and shallow inspection as review gaps", () => { + const result = validateDiscoveryReport({ + inspected_files: [], + inspected_areas: [], + candidates: [ + { + title: "Explain the product", + likely_area: "docs", + source_evidence: [{ path: "README.md", kind: "doc" }], + confidence: "low" + } + ] + }); + + expect(result.valid).toBe(true); + expect(result.gaps.map((gap) => gap.code)).toEqual([ + "shallow-inspection", + "shallow-inspection", + "missing-code-evidence", + "documentation-only-evidence", + "low-confidence" + ]); + }); + + it("rejects candidates without source evidence", () => { + const result = validateDiscoveryReport({ + inspected_files: ["src/app.ts"], + inspected_areas: ["source"], + candidates: [ + { + title: "Run app", + likely_area: "app", + source_evidence: [], + confidence: "medium" + } + ] + }); + + expect(result.valid).toBe(false); + expect(result.issues[0]).toMatchObject({ code: "missing-evidence", candidate: "Run app" }); + }); +}); + +describe("buildCapabilityDiscoveryPrompt", () => { + it("instructs agents to inspect code and return JSON without writing capability files", () => { + const prompt = buildCapabilityDiscoveryPrompt("/repo"); + + expect(prompt).toContain("Project root: /repo"); + expect(prompt).toContain("Inspect source code, tests, routes, handlers, UI flows, data models, scripts, configuration, and documentation"); + expect(prompt).toContain("README files, package metadata, and docs as supporting context only"); + expect(prompt).toContain("Do not create or overwrite `.capability.yaml` files"); + expect(prompt).toContain('"candidates"'); + }); +}); diff --git a/packages/core/tests/discoveryOrganization.test.ts b/packages/core/tests/discoveryOrganization.test.ts new file mode 100644 index 0000000..8fc8933 --- /dev/null +++ b/packages/core/tests/discoveryOrganization.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { organizeDiscoveredCapabilityMap } from "../src/discovery.js"; + +describe("organizeDiscoveredCapabilityMap", () => { + it("groups candidates into stable area folders and readable capability IDs", () => { + const organized = organizeDiscoveredCapabilityMap({ + inspected_files: ["src/auth/login.ts", "src/billing/invoices.ts"], + inspected_areas: ["auth", "billing"], + candidates: [ + { + title: "Log in users", + likely_area: "Accounts / Authentication", + source_evidence: [{ path: "src/auth/login.ts", kind: "source" }], + confidence: "high" + }, + { + title: "Send invoices", + likely_area: "Billing", + source_evidence: [{ path: "src/billing/invoices.ts", kind: "source" }], + confidence: "medium" + } + ] + }); + + expect(organized.summary).toContain("2 discovered capabilities organized into 2 areas"); + expect(organized.areas.map((area) => area.area)).toEqual(["accounts/authentication", "billing"]); + expect(organized.capabilities.map((capability) => capability.id)).toEqual([ + "accounts/authentication/log-in-users", + "billing/send-invoices" + ]); + expect(organized.capabilities[0].filePath).toBe(".capabilities/accounts/authentication/log-in-users.capability.yaml"); + }); + + it("suggests dependencies from explicit candidate relationships", () => { + const organized = organizeDiscoveredCapabilityMap({ + inspected_files: ["src/auth/session.ts", "src/orders/checkout.ts"], + inspected_areas: ["auth", "orders"], + candidates: [ + { + title: "Maintain sessions", + likely_area: "accounts", + source_evidence: [{ path: "src/auth/session.ts", kind: "source" }], + confidence: "high" + }, + { + title: "Checkout orders", + likely_area: "orders", + source_evidence: [{ path: "src/orders/checkout.ts", kind: "source" }], + confidence: "medium", + depends_on: ["Maintain sessions"] + } + ] + }); + + expect(organized.dependency_suggestions).toEqual([ + { + from: "orders/checkout-orders", + to: "accounts/maintain-sessions", + reason: 'Candidate explicitly listed "Maintain sessions" as a dependency.' + } + ]); + expect(organized.capabilities.find((capability) => capability.id === "orders/checkout-orders")?.depends_on).toEqual([ + "accounts/maintain-sessions" + ]); + }); + + it("flags ambiguous grouping and weak evidence for human review", () => { + const organized = organizeDiscoveredCapabilityMap({ + inspected_files: ["README.md"], + inspected_areas: ["docs"], + candidates: [ + { + title: "Describe roadmap", + likely_area: "", + source_evidence: [{ path: "README.md", kind: "doc" }], + confidence: "low" + } + ] + }); + + expect(organized.capabilities[0]).toMatchObject({ + id: "general/describe-roadmap", + area: "general" + }); + expect(organized.review_gaps).toContain("general/describe-roadmap: Area was inferred as general and needs human review."); + expect(organized.review_gaps).toContain("general/describe-roadmap: Candidate lacks concrete implementation evidence."); + }); +});