From 56f60034b120ed343d57f56837e54b156a074f42 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 22:27:36 +0300 Subject: [PATCH 01/24] fix: improve terminal search recovery Keep terminal target failures actionable, preserve symbol and warning provenance, and verify CLI/MCP parity through focused presentation tests. --- .../unified-search-presentation.test.ts | 228 +++++++++++++++++- .../src/shared/unified-search-presentation.ts | 83 ++++++- .../shared/unified-search-status-text.test.ts | 50 ++++ .../src/shared/unified-search-text.test.ts | 110 ++++++++- .../mcp/src/shared/unified-search-text.ts | 43 +++- src/commands/search.test.ts | 10 +- 6 files changed, 501 insertions(+), 23 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 76609a25..de963df7 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -237,7 +237,7 @@ describe("projectUnifiedSearchPresentation", () => { }); }); - it("groups symbol source readiness with code", () => { + it("preserves symbol source readiness as symbols", () => { const presentation = projectUnifiedSearchPresentation( completed({ query: { raw: "router", sources: ["symbol"] }, @@ -254,7 +254,7 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.sources).toEqual([ { - kind: "code", + kind: "symbols", entries: [ { state: "searched", @@ -267,8 +267,25 @@ describe("projectUnifiedSearchPresentation", () => { ]); }); - it.each(["MISSING", "UNRESOLVABLE", "FUTURE_STATE"] as const)( - "treats source state %s as unavailable and suppresses pivots", + it("source and warning provenance keeps code and symbols as separate lanes", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ source: "CODE", codeIndexState: "CURRENT" }), + source({ source: "SYMBOL", codeIndexState: "CURRENT" }), + ], + }), + ); + + expect(presentation.sources.map((group) => group.kind)).toEqual([ + "code", + "symbols", + ]); + }); + + it.each(["MISSING", "FUTURE_STATE"] as const)( + "terminal target recovery keeps source state %s unavailable with conservative pivots", (state) => { const presentation = projectUnifiedSearchPresentation( completed({ @@ -300,6 +317,103 @@ describe("projectUnifiedSearchPresentation", () => { }, ); + it("terminal target recovery selects exact unresolvable and missing states", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + source({ + targetLabel: "github:owner/repo#main", + indexingStatus: "UNRESOLVABLE", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + freshness: "indexing", + freshnessReason: "no_current_fallback", + requested: { repoUrl: "https://github.com/owner/repo" }, + availableVersions: [], + availableRefs: [], + }, + resultCount: 0, + }), + source({ + source: "docs", + targetLabel: "site:docs.example.com", + indexingStatus: "NOT_FOUND", + resultCount: 0, + }), + source({ + targetLabel: "opaque:target", + indexingStatus: "UNRESOLVABLE", + resultCount: 0, + }), + source({ + targetLabel: "npm:express@4.18.2", + indexingStatus: "UNRESOLVABLE", + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ + kind: "verify_target", + families: ["package", "repository", "site", "unknown"], + }); + expect(presentation.action).not.toHaveProperty("searchRef"); + }); + + it("terminal target recovery preserves site suggestion precedence", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + source: "docs", + targetLabel: "site:docs.example.com", + indexingStatus: "UNRESOLVABLE", + suggestedSiteTargets: ["site:docs.example.com/guide"], + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ kind: "site_retry" }); + }); + + it("terminal target recovery preserves indexed alternative precedence", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + freshness: "indexing", + freshnessReason: "no_current_fallback", + availableVersions: [{ version: "4.17.0", ref: "v4.17.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ + kind: "indexed_alternative", + target: "npm:express@4.18.2", + category: "version", + value: "4.17.0", + }); + }); + it.each(["docs", "auto"] as const)( "uses neutral docs provenance for contributor-less %s sources", (sourceName) => { @@ -1485,12 +1599,14 @@ describe("projectUnifiedSearchPresentation", () => { { kind: "query", message: "unknown qualifier" }, { kind: "ignored_filter", - source: "site:expressjs.com", + source: "docs", + target: "site:expressjs.com", values: ["category"], }, { kind: "incompatible_query_feature", - source: "site:expressjs.com", + source: "docs", + target: "site:expressjs.com", values: ["exact_name"], }, ]); @@ -1505,6 +1621,106 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.action).toEqual({ kind: "new_search" }); }); + it("source and warning provenance keeps normalized lanes and targets", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + source: "DOCS", + targetLabel: "npm:express", + ignoredFilters: ["fileIntent"], + }), + source({ + source: "SYMBOL", + targetLabel: "npm:express", + incompatibleFilters: ["lang"], + }), + source({ + source: "AUTO", + targetLabel: "site:docs.example.com", + ignoredQueryFeatures: ["name"], + }), + source({ + source: "Future-Lane", + targetLabel: "opaque-target", + incompatibleQueryFeatures: ["kind"], + }), + source({ + source: "", + targetLabel: "npm:empty", + ignoredFilters: ["category"], + }), + ], + }), + ); + + expect(presentation.warnings).toEqual([ + { + kind: "ignored_filter", + source: "docs", + target: "npm:express", + values: ["fileIntent"], + }, + { + kind: "incompatible_filter", + source: "symbol", + target: "npm:express", + values: ["lang"], + }, + { + kind: "ignored_query_feature", + source: "auto", + target: "site:docs.example.com", + values: ["name"], + }, + { + kind: "incompatible_query_feature", + source: "future-lane", + target: "opaque-target", + values: ["kind"], + }, + { + kind: "ignored_filter", + source: undefined, + target: "npm:empty", + values: ["category"], + }, + ]); + expect(presentation.trustLimits).toEqual( + expect.arrayContaining([ + { + kind: "constraint", + constraint: "ignored_filter", + source: "docs", + target: "npm:express", + values: ["fileIntent"], + }, + { + kind: "constraint", + constraint: "incompatible_filter", + source: "symbol", + target: "npm:express", + values: ["lang"], + }, + { + kind: "constraint", + constraint: "ignored_query_feature", + source: "auto", + target: "site:docs.example.com", + values: ["name"], + }, + { + kind: "constraint", + constraint: "incompatible_query_feature", + source: "future-lane", + target: "opaque-target", + values: ["kind"], + }, + ]), + ); + }); + it("suppresses generic pivots for evidence limits and prefers indexed alternatives", () => { const presentation = projectUnifiedSearchPresentation( completed({ diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 6f922049..6b92b1cf 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1,3 +1,4 @@ +import { isKnownRegistry } from "./package-spec.js"; import type { LeanDocCoverage, UnifiedSearchCompletedPayload, @@ -40,6 +41,7 @@ export type UnifiedSearchLifecycle = export type UnifiedSearchSourceKind = | "code" + | "symbols" | "docs" | "repository_docs" | "site_docs"; @@ -170,6 +172,7 @@ export type UnifiedSearchTrustLimit = kind: "constraint"; constraint: UnifiedSearchConstraintKind; source?: string; + target?: string; values: string[]; } | { kind: "mutable_evidence" }; @@ -179,6 +182,7 @@ export type UnifiedSearchWarning = | { kind: UnifiedSearchConstraintKind; source?: string; + target?: string; values: string[]; }; @@ -197,6 +201,7 @@ export type UnifiedSearchAction = kind: "query_rewrite"; rewrites: UnifiedSearchRewriteKind[]; } + | { kind: "verify_target"; families: UnifiedSearchTargetFamily[] } | { kind: "none" }; export type UnifiedSearchRewriteKind = @@ -206,6 +211,19 @@ export type UnifiedSearchRewriteKind = | "code_grep" | "site_shorter_or_broader"; +export type UnifiedSearchTargetFamily = + | "package" + | "repository" + | "site" + | "unknown"; + +const TARGET_FAMILY_ORDER: UnifiedSearchTargetFamily[] = [ + "package", + "repository", + "site", + "unknown", +]; + export interface UnifiedSearchPresentation { availability: UnifiedSearchAvailability; lifecycle: UnifiedSearchLifecycle; @@ -439,7 +457,8 @@ function sourceKind( entry: UnifiedSearchSourceStatusPayload, ): UnifiedSearchSourceKind { const source = entry.source.toLowerCase(); - if (source === "code" || source === "symbol") return "code"; + if (source === "code") return "code"; + if (source === "symbol") return "symbols"; if (isSiteTarget(entry.targetLabel, entry)) return "site_docs"; return entry.targetResolution?.served?.repoUrl ? "repository_docs" : "docs"; } @@ -688,7 +707,13 @@ function addConstraints( const target = entry.targetLabel; for (const [constraint, values] of sourceConstraints(entry)) { if (values?.length) - add({ kind: "constraint", constraint, source: target, values }); + add({ + kind: "constraint", + constraint, + source: normalizeSourceLane(entry.source), + target: target || undefined, + values, + }); } } @@ -712,14 +737,20 @@ function projectWarnings( warnings.push({ kind: "query", message }); } for (const entry of sourceStatus ?? []) { - const source = entry.targetLabel; + const source = normalizeSourceLane(entry.source); + const target = entry.targetLabel || undefined; for (const [kind, values] of sourceConstraints(entry)) { - if (values?.length) warnings.push({ kind, source, values }); + if (values?.length) warnings.push({ kind, source, target, values }); } } return warnings; } +function normalizeSourceLane(source: string | undefined): string | undefined { + const normalized = source?.trim().toLowerCase(); + return normalized || undefined; +} + function projectAlternatives( progress: UnifiedSearchProgressPayload | undefined, sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, @@ -1149,11 +1180,15 @@ function projectAction(input: ActionInput): UnifiedSearchAction { if (hasIndexing) { const alternative = firstAlternative(input.alternatives); if (alternative) return alternative; - return { kind: "new_search" }; } if (input.siteSuggestions.length > 0) { return { kind: "site_retry" }; } + const terminalFamilies = terminalTargetFamilies(input.snapshot.sourceStatus); + if (terminalFamilies.length > 0) { + return { kind: "verify_target", families: terminalFamilies }; + } + if (hasIndexing) return { kind: "new_search" }; if ( input.trustLimits.some( (limit) => @@ -1188,6 +1223,44 @@ function projectAction(input: ActionInput): UnifiedSearchAction { return { kind: "query_rewrite", rewrites }; } +function terminalTargetFamilies( + sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, +): UnifiedSearchTargetFamily[] { + const families = new Set(); + for (const entry of sourceStatus ?? []) { + if ( + entry.indexingStatus !== "NOT_FOUND" && + entry.indexingStatus !== "UNRESOLVABLE" && + entry.codeIndexState !== "NOT_FOUND" && + entry.codeIndexState !== "UNRESOLVABLE" + ) { + continue; + } + families.add(classifyTargetFamily(entry)); + } + return TARGET_FAMILY_ORDER.filter((family) => families.has(family)); +} + +function classifyTargetFamily( + entry: UnifiedSearchSourceStatusPayload, +): UnifiedSearchTargetFamily { + if (isSiteTarget(entry.targetLabel, entry)) return "site"; + const target = entry.targetLabel.trim().toLowerCase(); + if ( + target.startsWith("github:") || + entry.targetResolution?.requested?.repoUrl || + entry.targetResolution?.resolvedRequested?.repoUrl || + entry.targetResolution?.served?.repoUrl + ) { + return "repository"; + } + const separator = target.indexOf(":"); + if (separator > 0 && isKnownRegistry(target.slice(0, separator))) { + return "package"; + } + return "unknown"; +} + function firstAlternative( alternatives: UnifiedSearchAlternativeFacts[], ): UnifiedSearchAction | undefined { diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index 4a11e7d0..3024755f 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -134,6 +134,56 @@ describe("renderUnifiedSearchStatusText", () => { expect(text).toContain("Search search-ref-empty | completed"); }); + it("terminal target recovery renders typed guidance for stored results", () => { + const payload: UnifiedSearchStatusCompletedPayload = { + completed: true, + searchRef: "search-ref-terminal", + result: result({ + sourceStatus: [ + { + source: "CODE", + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + }, + { + source: "CODE", + targetLabel: "github:owner/repo#main", + indexingStatus: "UNRESOLVABLE", + targetResolution: { + freshness: "indexing", + freshnessReason: "no_current_fallback", + requested: { repoUrl: "https://github.com/owner/repo" }, + availableVersions: [], + availableRefs: [], + }, + }, + { + source: "DOCS", + targetLabel: "site:docs.example.com", + codeIndexState: "UNRESOLVABLE", + }, + { + source: "CODE", + targetLabel: "opaque:target", + indexingStatus: "NOT_FOUND", + }, + ], + }), + }; + const text = renderUnifiedSearchStatusText(payload); + + expect(text).toContain( + "Next: verify the package target; for repository-wide evidence, use its public GitHub repository.", + ); + expect(text).toContain( + "Next: verify the public GitHub repository target and ref.", + ); + expect(text).toContain("Next: verify the standalone site target."); + expect(text).toContain("Next: verify or replace the unavailable target."); + expect(text).not.toContain("rerun search later"); + expect(text).not.toContain("searchRef="); + }); + it("continues completed mutable evidence through one status action", () => { const payload: UnifiedSearchStatusCompletedPayload = { completed: true, diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index ae3fc47d..39862bb7 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -574,7 +574,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("Do not repeat"); }); - it("renders symbol source readiness as code", () => { + it("source and warning provenance renders symbol readiness separately", () => { const text = renderUnifiedSearchSuccess( completed([], { query: { raw: "router", sources: ["symbol"] }, @@ -588,10 +588,116 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("Searched: code"); + expect(text).toContain("Searched: symbols"); expect(text).not.toContain("repository docs"); }); + it("source and warning provenance renders mixed code and symbol readiness", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ source: "CODE", codeIndexState: "CURRENT" }), + source({ source: "SYMBOL", codeIndexState: "CURRENT" }), + ], + }), + ); + + expect(text).toContain("Searched: code, symbols"); + }); + + it("source and warning provenance renders lane and target attribution", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + source: "DOCS", + targetLabel: "npm:express", + ignoredFilters: ["fileIntent"], + }), + source({ + source: "SYMBOL", + targetLabel: "npm:express", + ignoredQueryFeatures: ["name"], + }), + source({ + source: "Future-Lane", + targetLabel: "opaque-target", + incompatibleFilters: ["lang"], + }), + ], + }), + ); + + expect(text).toContain("Ignored filter (docs on npm:express): fileIntent"); + expect(text).toContain( + "Ignored query feature (symbol on npm:express): name", + ); + expect(text).toContain( + "Incompatible filter (future-lane on opaque-target): lang", + ); + }); + + it("terminal target recovery renders one positive line per target family", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + }), + source({ + targetLabel: "github:owner/repo#main", + indexingStatus: "UNRESOLVABLE", + targetResolution: { + freshness: "indexing", + freshnessReason: "no_current_fallback", + requested: { repoUrl: "https://github.com/owner/repo" }, + availableVersions: [], + availableRefs: [], + }, + }), + source({ + source: "docs", + targetLabel: "site:docs.example.com", + codeIndexState: "UNRESOLVABLE", + }), + source({ + targetLabel: "opaque:target", + indexingStatus: "NOT_FOUND", + }), + ], + }), + ); + + expect(text).toContain( + "Next: verify the package target; for repository-wide evidence, use its public GitHub repository.", + ); + expect(text).toContain( + "Next: verify the public GitHub repository target and ref.", + ); + expect(text).toContain("Next: verify the standalone site target."); + expect(text).toContain("Next: verify or replace the unavailable target."); + expect(text).not.toContain("rerun search later"); + expect(text).not.toContain("searchRef"); + expect(text.match(/Next:/g)).toHaveLength(4); + + const cliText = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + }), + ], + }), + { actionSyntax: "cli" }, + ); + expect(cliText).toContain( + "Next: verify the package target; for repository-wide evidence, use its public GitHub repository.", + ); + expect(cliText).not.toContain("search_status"); + }); + it.each(["docs", "auto"] as const)( "uses a neutral docs label for contributor-less %s sources", (sourceName) => { diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index f5d0c96f..1e915f9c 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -376,6 +376,8 @@ function compactSourceRank(kind: UnifiedSearchSourceKind): number { return 2; case "code": return 3; + case "symbols": + return 4; } } @@ -545,11 +547,13 @@ function formatGroupedSource( const identity = source.kind === "code" ? "code" - : source.kind === "repository_docs" - ? "repository docs" - : source.kind === "site_docs" - ? `${formatDocumentationSourceIdentity(source, entry)} docs` - : "docs"; + : source.kind === "symbols" + ? "symbols" + : source.kind === "repository_docs" + ? "repository docs" + : source.kind === "site_docs" + ? `${formatDocumentationSourceIdentity(source, entry)} docs` + : "docs"; const qualifiers: string[] = []; if (coverageDetails) qualifiers.push(coverageDetails); return `${identity}${qualifiers.length > 0 ? ` (${qualifiers.join("; ")})` : ""}`; @@ -656,7 +660,10 @@ function appendPresentationWarnings( ); else { const label = warning.kind.replaceAll("_", " "); - const source = warning.source ? ` (${warning.source})` : ""; + const attribution = [warning.source, warning.target] + .filter((value): value is string => Boolean(value)) + .join(" on "); + const source = attribution ? ` (${attribution})` : ""; const value = ` - ${capitalize(label)}${source}: ${warning.values.join(", ")}`; lines.push( options.useColors ? `${colors.yellow}${value}${colors.reset}` : value, @@ -731,6 +738,12 @@ function appendPresentationAction( lines.push("Next: retry one suggested site target explicitly."); return; } + if (action.kind === "verify_target") { + for (const family of action.families) { + lines.push(`Next: ${formatTargetVerification(family)}.`); + } + return; + } if (action.kind === "query_rewrite") { lines.push( `Next: ${action.rewrites @@ -740,6 +753,24 @@ function appendPresentationAction( } } +function formatTargetVerification( + family: Extract< + UnifiedSearchAction, + { kind: "verify_target" } + >["families"][number], +): string { + switch (family) { + case "package": + return "verify the package target; for repository-wide evidence, use its public GitHub repository"; + case "repository": + return "verify the public GitHub repository target and ref"; + case "site": + return "verify the standalone site target"; + case "unknown": + return "verify or replace the unavailable target"; + } +} + function formatRewrite( rewrite: NonNullable< Extract diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 025624aa..3abc65ff 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -1253,7 +1253,7 @@ describe("searchAction", () => { expect(output).toContain("- site:example.com"); expect(output).toContain("Indexing: site:example.com docs"); expect(output).toContain( - "Incompatible filter (site:example.com): language", + "Incompatible filter (docs on site:example.com): language", ); expect(output).toContain("Suggested sites: site:docs.example.com"); expect(output).toContain("More suggested sites omitted"); @@ -1306,7 +1306,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Ignored filter (npm:express@4.18.2): fileIntent"); + expect(output).toContain( + "Ignored filter (docs on npm:express@4.18.2): fileIntent", + ); expect(output).not.toContain("Note: docs on npm:express@4.18.2"); consoleSpy.mockRestore(); }); @@ -1354,10 +1356,10 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain( - "Ignored query feature (npm:express@4.18.2): kind", + "Ignored query feature (docs on npm:express@4.18.2): kind", ); expect(output).toContain( - "Incompatible query feature (npm:express@4.18.2): name", + "Incompatible query feature (docs on npm:express@4.18.2): name", ); expect(output).not.toContain("Note: docs on npm:express@4.18.2"); consoleSpy.mockRestore(); From e0057e6f03a586ddd9d8d69181646620aa540f2c Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 22:40:57 +0300 Subject: [PATCH 02/24] docs: clarify search target addressing Document canonical Swift and Zig package identities and the package-versus-repository scope boundary across CLI, MCP, and public skills. Record terminal recovery contracts and the dual-package patch release fragment. --- changes/search-client-recovery.fixed.md | 6 + docs/implementation/cli-commands.md | 9 +- docs/implementation/tools.md | 8 + ...rch-client-recovery-and-target-guidance.md | 459 ++++++++++++++++++ packages/mcp/src/mcp/instructions.test.ts | 10 + packages/mcp/src/mcp/instructions.ts | 2 +- .../mcp/src/tools/code-navigation-shared.ts | 4 +- packages/mcp/src/tools/list-files.test.ts | 20 + packages/mcp/src/tools/search.test.ts | 20 + packages/mcp/src/tools/search.ts | 4 +- .../githits-code/references/code-and-docs.md | 2 +- skills/githits-mcp/SKILL.md | 2 +- src/commands/search-registration.test.ts | 16 + src/commands/search.ts | 8 +- src/skills-packaging.test.ts | 24 + 15 files changed, 581 insertions(+), 13 deletions(-) create mode 100644 changes/search-client-recovery.fixed.md create mode 100644 docs/plans/search-client-recovery-and-target-guidance.md diff --git a/changes/search-client-recovery.fixed.md b/changes/search-client-recovery.fixed.md new file mode 100644 index 00000000..f200bd2f --- /dev/null +++ b/changes/search-client-recovery.fixed.md @@ -0,0 +1,6 @@ +--- +"githits": patch +"@githits/mcp": patch +--- + +- **Search recovery and target guidance** - Correct terminal target recovery, source provenance, and canonical package addressing. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index e29563cd..c9360747 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -226,17 +226,18 @@ Unified search spans indexed dependency and repository code, docs, and explicit **Decision guide.** Use `githits example` for canonical cross-project examples. Use `githits search` for indexed dependency/repository search. Use `githits search --source symbol` when you want symbol-shaped unified search. -**Targets.** `--in ` is repeatable and required. Package targets require explicit `registry:name[@version]` (for example `npm:express`, `pypi:requests@2.32.3`). Repo targets use `github:org/repo[#ref|@ref]`, `github.com/org/repo[#ref|@ref]`, or `https://github.com/org/repo[#ref|@ref]`; omitted refs request the backend default-branch intent. Exact standalone documentation sites use `site:` and normally pair with `--source docs`. User-facing output canonicalizes repo targets as `github:org/repo#ref` so refs can contain `@` safely. Exact duplicate targets are deduplicated while preserving order. Mixing target kinds in the same request is supported. +**Targets.** `--in ` is repeatable and required. Package targets require explicit `registry:name[@version]` (for example `npm:express`, `pypi:requests@2.32.3`) and inspect an indexed artifact/manifest root. Swift package targets use `swift:github.com//`; Zig package targets use `zig:gh//`. Use public GitHub repository targets for full repositories or sibling packages. Repo targets use `github:org/repo[#ref|@ref]`, `github.com/org/repo[#ref|@ref]`, or `https://github.com/org/repo[#ref|@ref]`; omitted refs request the backend default-branch intent. Exact standalone documentation sites use `site:` and normally pair with `--source docs`. User-facing output canonicalizes repo targets as `github:org/repo#ref` so refs can contain `@` safely. Exact duplicate targets are deduplicated while preserving order. Mixing target kinds in the same request is supported. **Sources and filters.** `--source docs|code|symbol` restricts results to one evidence type; omit it to let GitHits select the best sources. Use `--source symbol` when you want symbol-shaped search results. `--category` is the broad filter (`callable`, `type`, `module`, `data`, `documentation`); `--kind` is the precise taxonomy. `--path-prefix`, `--intent`, and `--public` narrow the result set further. For docs-only search, code/symbol-only filters (`category`, `kind`, `file_intent`, `public_only`) are ignored client-side because the backend docs source rejects them. `--name` and `--lang` compile into query qualifiers instead of becoming separate backend fields. **Intent filter.** When `--intent` is omitted, unified search sends no file-intent filter. Pass `--intent production` or another specific intent only when you want to narrow the result set. Some sources can still ignore `fileIntent`; when they do, the JSON `sourceStatus` block and terminal notes report that explicitly. **Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. The rendered `search-status` action is the concise way to continue; reissuing the same search is also valid and waits on the same underlying work. Ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). +**Terminal target recovery and provenance.** A completed empty result whose source status is exactly `NOT_FOUND` or `UNRESOLVABLE` renders positive target-verification guidance instead of `rerun search later` and does not fabricate a `searchRef`. Package guidance includes the public GitHub repository as the route to full-repository or sibling-package evidence; repository and site targets receive their corresponding verification guidance. Existing site suggestions and indexed alternatives remain higher-information actions. `SYMBOL` readiness is presented as `symbols`, separately from `code`, and target-scoped warnings identify both the lane and target (for example, `docs on npm:express`). Structured JSON keeps the source-status and warning values unchanged. The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Breakdown labels use `repo code hit(s)`, `repo symbol(s)`, `repo doc(s)`, and `docs page(s)`. Hits remain numbered and preserve follow-up locators in compact human form: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search | / target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Breakdown labels use `repo code hit(s)`, `repo symbol(s)`, `repo doc(s)`, and `docs page(s)`. Hits remain numbered and preserve follow-up locators in compact human form: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search | / target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. Readiness labels distinguish `code` from `symbols`, including in mixed-source output. The representative CLI n8n active-empty output shape is: @@ -253,7 +254,7 @@ Next: githits search-status --wait 20 **Highlighting and width.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, target details remain plain, and the optional session row is dim. Color never carries meaning or changes wording. CLI target details and hit summaries wrap to the current terminal width; MCP uses the shared 80-column fallback. -**Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups structured readiness and trust facts under each target, including searched, waiting, unavailable, stale, provisional, and capped coverage. Exact requested/fresh/served divergence appears once only when identities differ. Raw reason codes, indexing references, promoted duplicate warnings, opaque evidence prose, and the exact `evidenceNotice` remain in JSON. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable next action. +**Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups structured readiness and trust facts under each target, including searched, waiting, unavailable, stale, provisional, and capped coverage. Exact requested/fresh/served divergence appears once only when identities differ. Raw reason codes, indexing references, promoted duplicate warnings, opaque evidence prose, and the exact `evidenceNotice` remain in JSON. Constraint and warning text retains separate raw lane and target provenance, such as `Ignored filter (docs on npm:express): fileIntent`; known lanes are lowercased and unknown non-empty lanes pass through lowercased. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable next action. Contributor-bearing rows omit redundant pair-level `resultCount`, pair-level `coverage`, and healthy resolution metadata from the compact JSON projection. Other source-status signals remain unchanged: ignored / incompatible filters and query features, terminal indexing notes, promoted freshness warnings, and ordered standalone-site recovery targets. Site suggestions come from `suggestedSiteTargets`; the exact `suggestedSiteTargetsTruncated` Boolean is retained whenever suggestions are present. They are advisory labels to retry explicitly, not aliases, and the client never selects or retries one automatically. @@ -716,7 +717,7 @@ Lists files in an indexed dependency. `[spec] [path-prefix]` positionals mirror **`stdout` vs `stderr` routing (plain mode).** Truncation warnings (`More files available — pass --limit higher …`) and empty-result hints go to **stderr**, not stdout, so they stay visible to humans without polluting pipes. In `--verbose` the same text renders inline. -**Addressing ambiguity guard.** In `--repo-url` mode, a positional that matches a known registry prefix (`npm:`, `pypi:`, `hex:`, `crates:`, `nuget:`, `maven:`, `zig:`, `vcpkg:`, `packagist:`) is rejected with a "looks like a package spec" error — catches `code files npm:express --repo-url …` typos that would otherwise silently interpret the spec as a path prefix. +**Addressing ambiguity guard.** In `--repo-url` mode, a positional that matches a known registry prefix (`npm:`, `pypi:`, `hex:`, `crates:`, `nuget:`, `maven:`, `zig:`, `vcpkg:`, `packagist:`, `rubygems:`, `go:`, `swift:`) is rejected with a "looks like a package spec" error — catches `code files npm:express --repo-url …` typos that would otherwise silently interpret the spec as a path prefix. This list mirrors `PKGSEER_REGISTRY_ARGS`. **Exit codes.** `0` on success (including empty results — absence of files is not an error). `1` on error (authentication, indexing, invalid arguments, backend failures). diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 70ff3c1a..1f934064 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -145,6 +145,8 @@ Treat failures as live backend or contract findings, not deterministic unit-test **Standalone-site recovery.** `search` accepts exact documentation targets as `site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels are preserved in order for missing or ambiguous sites, together with the exact `suggestedSiteTargetsTruncated` Boolean. The compact source-status row becomes actionable even when it has no note or lifecycle warning, and MCP text-v1 renders replayable target labels plus an omitted-candidates notice when truncated. Suggestions are advisory rather than aliases: active known sessions keep polling their current `searchRef`, while completed or terminal recovery can expose one explicit site-retry action without selecting a label automatically. Terminal missing or ambiguous results can omit `searchRef` and instead expose recovery guidance. +**Terminal target recovery.** A completed empty search with an exact `NOT_FOUND` or `UNRESOLVABLE` source state renders positive target-verification guidance in text-v1, with one line per affected package, repository, site, or unknown display family. Package guidance includes the public GitHub repository for full-repository or sibling-package scope. This action has no `searchRef` and does not emit `rerun search later`; existing site suggestions and indexed alternatives remain higher-information actions. Other terminal session statuses retain their conservative new-search behavior. + **Documentation sources.** DOCS `sourceStatus` rows retain bounded physical `contributors` and coverage in JSON. Text places the user-meaningful readiness state under its target, using `Indexing`, `Searched`, `Available now`, `Unavailable`, @@ -166,6 +168,12 @@ status and unknown-status handling remains conservative, while formatter—contributors are never copied onto generic progress targets, and `allowPartialResults` retains its separate pair-omission meaning. +Completed empty results distinguish the `code` and `symbols` readiness lanes. Text +constraint and warning facts retain separate raw lane and target provenance, for +example `Ignored filter (docs on npm:express): fileIntent`; known lanes are +lowercased and unknown non-empty lanes pass through lowercased. JSON keeps the +lossless `sourceStatus` and warning values unchanged. + ### `pkg_info` response shape **Default MCP text + JSON opt-in.** `pkg_info` defaults to compact triage text for agent turns: identity/license, description, repository popularity (stars/forks/issues and `[ARCHIVED]` when available), publish age, downloads, and explicit vulnerability status. `verbose: true` adds GitHub language/topics/last-pushed, recent advisories, and recent changes. `format: "json"` returns a lean payload designed for programmatic consumers. Fields that do not add caller value are deliberately omitted. Null scalars are omitted; blocks (`github`, `downloads`, `recentChanges`) are omitted entirely when they carry no actionable data. `vulnerabilities` is emitted whenever the backend reports a numeric vulnerability count, including `total: 0`, so callers can distinguish "no active vulnerabilities in latest" from unavailable data; when present, recent advisory severity values include a CVSS-banded `severityLabel` (`critical` ≥9, `high` ≥7, `medium` ≥4, else `low`) for agent convenience. diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md new file mode 100644 index 00000000..a822c7ae --- /dev/null +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -0,0 +1,459 @@ +# Plan: Search client recovery and target guidance + +## Status + +- Overall: IMPLEMENTED — FINAL VALIDATION/REVIEW PENDING +- Current phase: Phase 1 — deterministic client recovery and canonical target + guidance implemented; final validation/review pending +- Later phase: Phase 2 — terminal backend failure details (BLOCKED on backend #2133) +- Runtime implementation: commit `56f6003`; focused runtime evidence: 329 tests + pass and typecheck passes. +- Last verified: 2026-08-28 + +## Problem and expected outcome + +The unified search backend already returns enough structured state for the client to +distinguish invalid or unresolvable targets, symbol results, and the lane that ignored +a filter. The shared CLI/MCP text projection discards those distinctions: + +- `NOT_FOUND` and `UNRESOLVABLE` sources become `Next: rerun search later`, which + sends callers into a futile retry loop; +- `SYMBOL` readiness is presented as `code`, so a symbol-only search can look as if + the wrong lane ran; and +- ignored-filter warnings retain the target but lose the source lane, so a docs-lane + warning can appear to describe the whole target. + +Separately, all public target guidance describes package coordinates as generic +`registry:name[@version]`. That is incomplete for Swift and Zig, whose verified +canonical identities are repository-shaped package names. It also does not explain +that package targets are artifact/manifest-root scoped rather than full-repository +searches. + +When this plan is complete: + +- terminal target failures tell CLI and MCP text callers to verify or replace the + target without advising an unchanged retry; +- symbol readiness is labelled as symbols, independently from code readiness; +- ignored-filter text identifies both the lane and target; +- search help, schemas, stable MCP guidance, and public Agent Skill guidance include + verified Swift/Zig forms and the package-versus-repository scope boundary; and +- once the backend exposes typed terminal failure metadata, `FAILED` search sessions + display an actionable cause without rendering opaque backend prose. + +## Verified current state + +### Client behavior + +- `packages/mcp/src/shared/unified-search-presentation.ts` owns the projection used by + both CLI and MCP text. Runtime commit `56f6003` maps `source === "symbol"` to + `symbols`, preserves separate lane and target provenance, and derives a typed + target-verification action for completed empty exact terminal source states. +- `packages/mcp/src/shared/unified-search-text.ts` renders target verification as + positive family-specific guidance without a retry-later directive or fabricated + `searchRef`; other terminal session statuses retain their existing new-search + behavior. +- `packages/mcp/src/shared/unified-search-response.ts` preserves source statuses and + lane-aware warning information in JSON. The defect is confined to text projection; + the success JSON envelope does not need a compatibility change in Phase 1. +- Focused unit, CLI, MCP, and parity tests cover terminal target verification, + distinct symbol readiness, and lane-aware warning text. The final repository + validation and review remain pending. +- `packages/mcp/src/shared/package-spec.ts` validates registry and syntax only. It + deliberately does not own backend package identity conventions. + +### Target guidance + +Canonical target guidance was incomplete across these user-facing boundaries; the +owned guidance surfaces below now carry the verified forms and scope boundary: + +- the CLI search description and `--in` option in `src/commands/search.ts`; +- the MCP search target schema in `packages/mcp/src/tools/search.ts`; +- the code-navigation target schema in + `packages/mcp/src/tools/code-navigation-shared.ts`; +- the stable MCP quick-start preamble in + `packages/mcp/src/mcp/instructions.ts` and its exact public skill copy in + `skills/githits-mcp/SKILL.md`; +- `skills/githits-code/references/code-and-docs.md`; +- the target syntax in `docs/implementation/cli-commands.md`; and +- the text/search scope contracts in `docs/implementation/tools.md`, which need the + missing package-scope and recovery rules rather than replacement syntax wording. + +Live smoke evidence established these canonical forms: + +- Swift: `swift:github.com//`; +- Zig: `zig:gh//`. + +Short forms such as `swift:vapor`, `swift:vapor/vapor`, and +`zig:zigzap/zap` returned `NOT_FOUND`. Client-side alias normalization would be +guesswork and would conflict with backend canonical identity ownership. + +### Backend issue boundary + +The backend defects have been filed with runnable repros: + +- [#2128](https://github.com/githits-com/pkgseer-backend/issues/2128) — exact + `name:` qualifiers remove valid symbol matches; +- [#2129](https://github.com/githits-com/pkgseer-backend/issues/2129) — standalone + site path prefixes are ignored; +- [#2130](https://github.com/githits-com/pkgseer-backend/issues/2130) — symbol-only + searches can complete empty while symbols are still indexing; +- [#2131](https://github.com/githits-com/pkgseer-backend/issues/2131) — repository + locators can leak a foreign package identity; +- [#2132](https://github.com/githits-com/pkgseer-backend/issues/2132) — identical + code ranges can occupy separate result slots; and +- [#2133](https://github.com/githits-com/pkgseer-backend/issues/2133) — terminal + search sessions expose no actionable failure metadata. + +The Zod hosted-doc duplicate repro was added to existing backend +[#2123](https://github.com/githits-com/pkgseer-backend/issues/2123). +The CLI must not locally deduplicate results, repair locators, reinterpret site scope, +or synthesize symbol indexing state for these backend-owned defects. + +## Assumptions and decisions + +1. Phase 1 handles only exact verified source statuses `NOT_FOUND` and + `UNRESOLVABLE` as target-verification failures. Unknown future statuses retain + conservative generic behavior rather than being guessed. +2. Existing higher-information recovery remains preferred: a backend-provided site + suggestion or indexed alternative is rendered before generic target verification. +3. Text advice is client-owned and typed. Backend `note` strings remain structured + JSON data and are not copied into terminal/MCP text. +4. Package target guidance documents canonical inputs; it does not add aliases, fuzzy + resolution, new flags, or automatic repository fallback. +5. Package targets inspect the indexed package artifact or manifest root. Repository + targets are the supported escape hatch for full monorepos and sibling packages. +6. `text-v1` evolves in place. The root CLI and public `@githits/mcp` package + both receive patch-level change fragments; feature PRs do not bump package + versions or edit released changelogs. +7. Phase 2 starts only after #2133 defines and deploys typed failure fields and their + retryability semantics. Field names and selections are intentionally not guessed. + +## Architecture + +The shared presentation model remains the single behavior boundary: + +```text +backend search/searchStatus payload + -> unified JSON payload (lossless structured state) + -> shared presentation projection + - source labels + - lane-aware warnings + - typed recovery action + -> CLI-native or MCP-native text renderer +``` + +The presentation layer owns semantic classification; renderers own surface wording. +This avoids duplicating status policy in CLI and MCP commands. Target syntax remains +owned by the existing parsers and service contract; public descriptions document that +contract without introducing a second normalization layer. + +Phase 1 changes only pure request-independent projection and text functions plus +descriptors/docs. It needs no service mock, dependency injection, container change, or +additional API field. Existing payload fixtures test it deterministically at the +presentation, CLI, MCP tool, and parity layers. + +## Cross-cutting constraints + +- **Security:** target labels and backend notes are untrusted text. The new action must + not interpolate additional backend prose or synthesize an executable command. The + broader search-metadata sanitation gap remains owned by + `docs/plans/terminal-text-sanitization.md` rather than being duplicated here. +- **Performance:** Phase 1 adds pure projection over the existing bounded source-status + list and no network fields or requests. It is not an optimization and needs no new + benchmark. +- **Compatibility:** structured JSON remains stable in Phase 1. `text-v1` wording + changes intentionally in place, with CLI/MCP parity maintained. +- **Migration and rollback:** there is no stored data, schema migration, or rollout + flag. Reverting the client patch restores prior text behavior without backend state + changes. +- **Operations:** deterministic fixtures are the acceptance gate. Authenticated smoke + validates the deployed service but transient timeouts and indexing races are recorded + as external evidence, not hidden with retries. +- **Documentation:** stable MCP instructions and the public skill copy remain exactly + aligned; implementation docs own lasting behavior after this plan is deleted. + +## Phase map + +1. **Phase 1 (IMPLEMENTED — FINAL VALIDATION/REVIEW PENDING):** CLI and MCP text give deterministic terminal-target recovery, + preserve symbol/warning provenance, and document canonical Swift/Zig and package + scope. +2. **Phase 2 (BLOCKED):** CLI and MCP expose an actionable typed cause for terminal + `FAILED` sessions after backend #2133 supplies the contract. + +## Phase 1: deterministic recovery and canonical guidance + +**Status:** IMPLEMENTED — FINAL VALIDATION/REVIEW PENDING + +Runtime behavior and focused tests are implemented in commit `56f6003`. The +guidance, durable documentation, and release metadata are now implemented; final +repository validation and review remain pending. + +**Expected outcome:** An invalid or unresolvable target cannot send a text caller into +an unchanged retry loop; symbol readiness and ignored-filter warnings identify the +actual lane; every relevant public search-addressing surface teaches the verified +Swift/Zig forms and package-scope boundary. + +**Assumptions:** The exact source states `NOT_FOUND` and `UNRESOLVABLE` are terminal; +the existing canonical target label is sufficient to distinguish package, GitHub +repository, standalone site, and unknown display families. + +**Unknowns or product decisions:** none. + +**Dependencies:** no backend change. Existing shared unified-search presentation and +text contracts remain the implementation boundary. + +### 1. Represent terminal target recovery explicitly + +Implemented in runtime commit `56f6003`: the shared action model has one typed +target-verification action. It is derived +only when a completed, empty search contains an exact `NOT_FOUND` or `UNRESOLVABLE` +source state and no more specific site suggestion or indexed alternative is available. +Evaluate exact terminal states carried by `indexingStatus` or `codeIndexState` after +site suggestions and indexed alternatives but before the generic +`hasIndexingTrustSignal` path. This precedence is required because a terminal +`UNRESOLVABLE` source may also carry target-resolution freshness `indexing`. + +Carry the deduplicated display families represented by all affected source entries: +package, GitHub repository, standalone site, or unknown. Derive those families from the +already-canonical target labels; do not rewrite the request or interpolate a new copy of +backend target text into the action. + +Render concise, surface-neutral guidance: + +- package: verify the registry package coordinate and version; if the coordinate is + correct but repository-wide evidence is needed, use its public GitHub repository; +- repository: verify the public GitHub repository and ref; +- site: verify the standalone site host/path; and +- unknown: verify or replace the unavailable target. + +Render one deduplicated positive guidance line per affected family. Do not emit +`rerun search later`, fabricate a `searchRef`, or turn terminal state into polling. +Preserve current polling for active sessions and current new-search handling for +terminal `DEFERRED`, `TIMEOUT`, `FAILED`, and unknown session statuses until Phase 2 +supplies better evidence. + +### 2. Preserve symbol and warning provenance + +Implemented in runtime commit `56f6003`: `symbols` is part of +`UnifiedSearchSourceKind` and backend `SYMBOL` source status maps to it. Source +ordering, compact labels, readiness summaries, empty-result summaries, and +action-related text keep code and symbol lanes distinct. A symbol-only completed search must +say `Searched: symbols`; code and symbol lanes must remain separately visible when +both are present. + +Change both constraint trust facts and projected ignored-filter warnings to carry +separate normalized backend lane and target fields, rather than storing a target in +the misleading `source` field. Normalize known `AUTO`, `CODE`, `DOCS`, and `SYMBOL` +values to lowercase. Preserve an unknown non-empty source as lowercase pass-through +instead of dropping the warning. Keep presentation source kinds such as `site_docs` +separate from the raw query lane. Render target-scoped source warnings as, for example, +`Ignored filter (docs on npm:express): fileIntent`. Query-wide warnings keep their +current query attribution. JSON warning and source-status shapes remain unchanged. + +### 3. Correct canonical target guidance + +The implementation updates all verified user-facing target descriptions listed above +with two compact rules: + +- Swift package names use `github.com//` after `swift:`; Zig package + names use `gh//` after `zig:`. +- Package targets are artifact/manifest-root scoped; use a public GitHub repository + target for the full repository or sibling packages. + +Keep the CLI search help compact and put the longer explanation in its description and +implementation docs. Update the shared code-navigation schema because it exposes the +same package target contract to `code_files`, `code_read`, and `code_grep`; do not +change those tools' first-80-character selection descriptions. + +`PACKAGE_TOOLS_PREAMBLE` and the exact corresponding paragraph in +`skills/githits-mcp/SKILL.md` were updated together. Generated plugin assets were +not edited directly; generation was run from canonical inputs and its empty diff +was inspected. + +### 4. Tests + +Implemented focused cases in the shared presentation/text tests and the existing +CLI/MCP search tests. They cover: + +- `NOT_FOUND` and `UNRESOLVABLE` package, repository, and site sources produce the + verification action and never contain `rerun search later`; +- multi-target and mixed-family terminal sources produce one deduplicated action + line per represented family without dropping the package-scope hint; +- a live-shaped `UNRESOLVABLE` fixture with target-resolution freshness `indexing` + still selects target verification after alternatives and before generic indexing; +- site suggestions and indexed alternatives remain higher-priority actions; +- active indexing still produces a polling action and `searchRef`; +- terminal session `FAILED` remains non-polling pending Phase 2; +- symbol-only, code-only, and mixed source readiness render distinct labels; +- a docs-lane ignored filter identifies `docs on ` and does not imply the code + lane ignored it; +- constraint trust facts and warning facts agree on the raw lane/target pair, including + symbol, hosted-doc, `AUTO`, and unknown-source cases; +- JSON payload regressions prove source status, warnings, hits, and error envelopes are + unchanged; and +- CLI/MCP parity covers the new recovery action and provenance wording. + +Descriptor/help contract assertions cover the Swift/Zig examples and package-scope +wording while preserving first-80 tool-description contracts. The MCP quick-start +parity test (`src/skills-packaging.test.ts`) and existing CLI registration/help tests +were updated instead of adding snapshots. The current +`MISSING`/`UNRESOLVABLE`/`FUTURE_STATE` table test was split: only `UNRESOLVABLE` +changes; `MISSING` and unknown future state keep conservative `new_search` +behavior. + +### 5. Documentation and release record + +Implemented in the owned documentation and guidance files: `cli-commands.md` and +`tools.md` now describe typed terminal recovery, the distinct symbol lane, warning +attribution, canonical Swift/Zig coordinates, and package/repository scope. Durable +target-failure guidance uses positive verification rather than retry-later wording. +The CLI target documentation's registry-prefix list mirrors canonical +`PKGSEER_REGISTRY_ARGS`, including Swift, RubyGems, and Go. + +Added one independent `changes/search-client-recovery.fixed.md` fragment with: + +```markdown +--- +"githits": patch +"@githits/mcp": patch +--- + +- **Search recovery and target guidance** - Correct terminal target recovery, + source provenance, and canonical package addressing. +``` + +The fragment describes corrected terminal recovery and target guidance in one +user-visible bullet. `CHANGELOG.md` and package versions remain untouched. + +### 6. Verification + +Run focused tests first, then the required repository checks: + +```text +bun test +bun run plugins:generate +bun run plugins:check +bun test +bun run typecheck +bun run format:check +bun run lint +bun run build +(cd packages/mcp && bun run build) +bun run validate:packages +bun run validate:packages:mcp-publish +bun run smoke:cli +bun run smoke:mcp +bun run smoke:cli:built +bun run smoke:mcp:built +``` + +With authenticated live access, verify valid +`swift:github.com/vapor/vapor` and `zig:gh/zigzap/zap` searches, invalid short-form +recovery, symbol-only labelling, and lane-aware warnings. Record any live rate limit or +transient timeout exactly; do not weaken deterministic tests around it. + +Because stable MCP instructions and public Agent Skill guidance change, run targeted +`bun run agent:e2e` workloads on the MCP descriptor/full profiles and the skills +surface with both Codex and Claude when practical. Inspect `tool-calls.json` and +`final.json` for canonical target use, absence of retry loops, `toolIssues`, +`instructionIssues`, and usefulness rather than relying on harness exit status. + +### Phase 1 acceptance criteria + +- CLI and MCP `text-v1` outputs for terminal `NOT_FOUND` and `UNRESOLVABLE` sources + contain target-verification guidance, contain no unchanged retry-later advice, and + expose no `searchRef`. +- Active progress still polls; site suggestions and indexed alternatives remain the + preferred recovery when supplied. +- Symbol-only output says `symbols`, mixed code/symbol readiness keeps both lanes, and + source warning text identifies both lane and target. +- Successful JSON payload schemas, values, and error envelopes are unchanged for the + same service fixture. +- CLI help, MCP argument schemas, stable MCP instructions, and public skill guidance + include the verified Swift/Zig forms and artifact/manifest-root scope rule. +- Focused tests, full repository checks, plugin generation/check, package validation, + source and built smoke suites, and targeted agent-eval inspection complete with + results recorded. +- One dual-package patch fragment exists; versions and released changelogs are + untouched. + +## Phase 2: terminal backend failure details + +**Status:** BLOCKED + +**Expected outcome:** A terminal `FAILED` search session exposes a bounded, typed cause +and an action consistent with backend-declared retryability while preserving any +returned evidence. + +**Assumptions:** Backend #2133 will expose stable machine-readable failure and +retryability data on every search progress surface that can terminate as `FAILED`. + +**Unknowns or product decisions:** exact field names, failure categories, message trust +contract, and retry semantics. Backend #2133 and a deployed schema resolve these before +Phase 2 can become READY. + +**Dependencies:** backend #2133 implemented, deployed, and documented; Phase 1 merged +and reoriented against current `origin/main`. + +### Entry gate + +Start only after backend #2133 is implemented, deployed, and documents: + +- a stable machine-readable failure code or category; +- a bounded display-safe message or client-owned mapping input; +- retryability and whether a fresh search can help; and +- field availability on both initial search and `search_status` progress responses. + +Re-run discovery against the deployed GraphQL schema before writing the detailed +increment. Report any contradiction with this plan immediately. + +### Expected outcome + +Select the smallest failure fields needed by every actual consumer. Compare the query +against compact text, verbose/text-v1, JSON, MCP, CLI, and internal callers before +changing it. Use conditional selections if detailed fields are mode-specific and add +wire-query tests proving compact modes do not over-fetch. + +Project typed terminal failure data through the shared payload and presentation model. +Render an actionable client-owned cause and recovery for `FAILED`; poll only when the +backend explicitly marks the state retryable and supplies a valid continuation +reference. Preserve any returned evidence. Never render opaque backend prose directly +or add timer/retry machinery. + +Update durable docs, add a separate dual-package patch fragment, and run the same +build, package, smoke, and agent-eval verification appropriate to changed MCP behavior. + +### Phase 2 acceptance criteria + +- Every deployed typed failure category has an explicit client projection and tested + recovery action; unknown categories fail conservatively without polling. +- CLI, MCP text, and JSON preserve terminal evidence and expose consistent failure + semantics without rendering opaque backend prose. +- GraphQL wire tests prove compact consumers fetch no unneeded failure detail and every + consumer that renders the cause selects the required fields. +- No timer, retry loop, or caller-ordering constraint is introduced. +- Durable docs, a separate dual-package patch fragment, full package validation, live + smoke, and relevant agent-eval evidence are complete. + +## Non-goals + +- Client-side workarounds for backend #2128–#2132 or hosted-doc #2123. +- Swift/Zig aliases, fuzzy resolution, automatic repository discovery, or registry + identity inference. +- Changing successful JSON envelopes in Phase 1. +- New flags, caches, queues, locks, feature flags, retry timers, or polling loops. +- Treating `crates:serde` as repository-wide; `serde_core` remains outside that + package target by design. +- Filing or coding around the single transient GraphQL timeout without reproducible + evidence. + +## Phase boundary and completion + +After Phase 1, commit the complete increment and use a fresh `origin/main` comparison +before beginning Phase 2. Do not mix speculative Phase 2 fields into the first PR. + +This plan remains active while #2133 blocks Phase 2. After both phases are implemented, +transfer all lasting contracts to `docs/implementation/`, verify no unresolved work +remains, and delete this plan. If the backend contract makes Phase 2 unnecessary or +materially different, revise the plan with the verified contradiction rather than +leaving stale instructions. diff --git a/packages/mcp/src/mcp/instructions.test.ts b/packages/mcp/src/mcp/instructions.test.ts index 8f30e7d7..19eea867 100644 --- a/packages/mcp/src/mcp/instructions.test.ts +++ b/packages/mcp/src/mcp/instructions.test.ts @@ -20,6 +20,16 @@ function buildLocal( } describe("buildLocalMcpQuickStart", () => { + it("documents canonical target guidance for package and repository scope", () => { + const quickStart = buildMcpQuickStart(); + + expect(quickStart).toContain("swift:github.com//"); + expect(quickStart).toContain("zig:gh//"); + expect(quickStart).toContain("artifact/manifest root"); + expect(quickStart).toContain("public GitHub repository"); + expect(quickStart).toContain("full repositories or sibling packages"); + }); + it("keeps deprecated instruction builders as exact compatibility aliases", () => { expect(buildMcpInstructions()).toBe(buildMcpQuickStart()); expect( diff --git a/packages/mcp/src/mcp/instructions.ts b/packages/mcp/src/mcp/instructions.ts index a846d162..d5e479f1 100644 --- a/packages/mcp/src/mcp/instructions.ts +++ b/packages/mcp/src/mcp/instructions.ts @@ -10,7 +10,7 @@ GitHits indexes public OSS/package evidence, not local workspaces, private repos When presenting \`get_example\` output, include source repository provenance/citations from GitHits' generated references/provenance section whenever present.`; -const PACKAGE_TOOLS_PREAMBLE = `Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use \`registry:name[@version]\`; repo targets use GitHub URLs. Prefer the default compact \`text-v1\` output; request JSON only when exact structured fields are necessary.`; +const PACKAGE_TOOLS_PREAMBLE = `Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use \`registry:name[@version]\` and inspect an indexed artifact/manifest root; Swift packages use \`swift:github.com//\` and Zig packages use \`zig:gh//\`. Use public GitHub repository targets for full repositories or sibling packages; repo targets use GitHub URLs. Prefer the default compact \`text-v1\` output; request JSON only when exact structured fields are necessary.`; const SEARCH_BULLET = "- `search` — discover relevant docs, code, tests, examples, and symbols in known packages/repos or exact `site:` documentation targets before reading exact files; retry advisory `suggestedSiteTargets` explicitly when returned."; diff --git a/packages/mcp/src/tools/code-navigation-shared.ts b/packages/mcp/src/tools/code-navigation-shared.ts index 774aedad..ad54c4c6 100644 --- a/packages/mcp/src/tools/code-navigation-shared.ts +++ b/packages/mcp/src/tools/code-navigation-shared.ts @@ -57,7 +57,7 @@ export const structuredCodeTargetObject: z.ZodObject = z.object( export const structuredCodeTargetSchema: z.ZodType = structuredCodeTargetObject.describe( - "Target: provide registry + package_name (package scope) or repo_url with optional git_ref (repo scope; omitted ref means default branch intent).", + "Target: provide registry + package_name (indexed artifact/manifest-root package scope) or repo_url with optional git_ref (public GitHub repository scope for the full repository or sibling packages; omitted ref means default branch intent). Swift package targets use swift:github.com//; Zig package targets use zig:gh//.", ) as z.ZodType; export const codeTargetSchema: z.ZodType = z.union([ @@ -66,7 +66,7 @@ export const codeTargetSchema: z.ZodType = z.union([ .string() .min(1) .describe( - "Compact target string. Package with explicit registry: `npm:react@18.2.0` or `npm:react` for latest release. Repository: `github:facebook/react`, `github.com/facebook/react`, `https://github.com/facebook/react`, or any repo form with `#HEAD` / `@HEAD` for a git ref. Output uses canonical `github:owner/repo#ref` form.", + "Compact target string. Package targets inspect an indexed artifact/manifest root: `npm:react@18.2.0` or `npm:react` for latest release; Swift uses `swift:github.com//` and Zig uses `zig:gh//`. Use a public GitHub repository target for the full repository or sibling packages: `github:facebook/react`, `github.com/facebook/react`, `https://github.com/facebook/react`, or any repo form with `#HEAD` / `@HEAD` for a git ref. Output uses canonical `github:owner/repo#ref` form.", ), ]); diff --git a/packages/mcp/src/tools/list-files.test.ts b/packages/mcp/src/tools/list-files.test.ts index d2ef83a2..a3aa6159 100644 --- a/packages/mcp/src/tools/list-files.test.ts +++ b/packages/mcp/src/tools/list-files.test.ts @@ -3,6 +3,8 @@ import { CodeNavigationIndexingError, CodeNavigationTargetNotFoundError, } from "@githits/core-internal"; +import { z } from "zod"; +import { getMcpToolDescriptors } from "../mcp/server.js"; import { createMockCodeNavigationService, defaultListFilesResult, @@ -14,6 +16,24 @@ function parseText(result: { content: Array<{ text: string }> }): unknown { } describe("createListFilesTool — metadata", () => { + it("documents canonical target guidance for package and repository scope", () => { + const descriptor = getMcpToolDescriptors().find( + (entry) => entry.name === "code_files", + ); + expect(descriptor).toBeDefined(); + const jsonSchema = z.toJSONSchema(z.object(descriptor?.schema ?? {})); + const targetSchema = JSON.stringify(jsonSchema.properties?.target); + + expect(targetSchema).toContain("swift:github.com//"); + expect(targetSchema).toContain("zig:gh//"); + expect(targetSchema).toContain("artifact/manifest-root"); + expect(targetSchema).toContain("public GitHub repository"); + expect(targetSchema).toContain("sibling packages"); + expect(descriptor?.description.slice(0, 80)).toBe( + "List indexed files and paths in any public GitHub repo/package; then use `code_r", + ); + }); + it("registers the correct tool name, description, and schema keys", () => { const tool = createListFilesTool(createMockCodeNavigationService()); expect(tool.name).toBe("code_files"); diff --git a/packages/mcp/src/tools/search.test.ts b/packages/mcp/src/tools/search.test.ts index 8ec99ba4..3c92dd88 100644 --- a/packages/mcp/src/tools/search.test.ts +++ b/packages/mcp/src/tools/search.test.ts @@ -3,6 +3,8 @@ import type { UnifiedSearchOutcome, UnifiedSearchParams, } from "@githits/core-internal"; +import { z } from "zod"; +import { getMcpToolDescriptors } from "../mcp/server.js"; import { createMockCodeNavigationService, defaultUnifiedSearchOutcome, @@ -11,6 +13,24 @@ import { import { createSearchTool } from "./search.js"; describe("searchTool", () => { + it("documents canonical target guidance for package and repository scope", () => { + const descriptor = getMcpToolDescriptors().find( + (entry) => entry.name === "search", + ); + expect(descriptor).toBeDefined(); + const jsonSchema = z.toJSONSchema(z.object(descriptor?.schema ?? {})); + const targetSchema = JSON.stringify(jsonSchema.properties?.target); + + expect(targetSchema).toContain("swift:github.com//"); + expect(targetSchema).toContain("zig:gh//"); + expect(targetSchema).toContain("artifact/manifest-root"); + expect(targetSchema).toContain("public GitHub repository"); + expect(targetSchema).toContain("sibling packages"); + expect(descriptor?.description.slice(0, 80)).toBe( + "Discover relevant evidence in a known target before exact grep: docs, specs, cod", + ); + }); + it("keeps the common path simple and delegates continuation details", () => { const tool = createSearchTool(createMockCodeNavigationService()); diff --git a/packages/mcp/src/tools/search.ts b/packages/mcp/src/tools/search.ts index 77c8cd60..84a70500 100644 --- a/packages/mcp/src/tools/search.ts +++ b/packages/mcp/src/tools/search.ts @@ -112,7 +112,7 @@ const structuredSearchTargetSchema: z.ZodType = site: z.string().optional(), }) .describe( - "Target: provide registry + package_name (package scope), repo_url with optional git_ref (repo scope; omitted ref means default branch intent), or site as site: for an exact documentation site.", + "Target: provide registry + package_name (indexed artifact/manifest-root package scope), repo_url with optional git_ref (public GitHub repository scope for the full repository or sibling packages; omitted ref means default branch intent), or site as site: for an exact documentation site. Swift package targets use swift:github.com//; Zig package targets use zig:gh//.", ); const searchTargetSchema = z.union([ @@ -121,7 +121,7 @@ const searchTargetSchema = z.union([ .string() .min(1) .describe( - "Compact discovery target string. Package with explicit registry: `npm:react@18.2.0` or `npm:react` for latest release. Repository: `github:facebook/react`, `github.com/facebook/react`, `https://github.com/facebook/react`, or any repo form with `#HEAD` / `@HEAD` for a git ref. Exact documentation site: `site:`. Output uses canonical `github:owner/repo#ref` form.", + "Compact discovery target string. Package targets inspect an indexed artifact/manifest root: `npm:react@18.2.0` or `npm:react` for latest release; Swift uses `swift:github.com//` and Zig uses `zig:gh//`. Use a public GitHub repository target for the full repository or sibling packages: `github:facebook/react`, `github.com/facebook/react`, `https://github.com/facebook/react`, or any repo form with `#HEAD` / `@HEAD` for a git ref. Exact documentation site: `site:`. Output uses canonical `github:owner/repo#ref` form.", ), ]); diff --git a/skills/githits-code/references/code-and-docs.md b/skills/githits-code/references/code-and-docs.md index 599358c4..b18bae8f 100644 --- a/skills/githits-code/references/code-and-docs.md +++ b/skills/githits-code/references/code-and-docs.md @@ -1,6 +1,6 @@ # GitHits Code And Docs CLI Reference -Package target syntax requires an explicit registry: `registry:name[@version]`, for example `npm:express@5.2.1`; omit `@version` for the latest release. Repository compact targets use `github:org/repo[#ref|@ref]`, `github.com/org/repo[#ref|@ref]`, or `https://github.com/org/repo[#ref|@ref]`; omitted refs request the backend default-branch intent. Exact standalone documentation sites use `site:`. Output uses canonical `github:org/repo#ref` formatting so refs can contain `@` safely. `code` commands also support `--repo-url [--git-ref ]`. +Package target syntax requires an explicit registry: `registry:name[@version]`, for example `npm:express@5.2.1`; omit `@version` for the latest release. Package targets inspect an indexed artifact/manifest root. Swift package targets use `swift:github.com//` and Zig package targets use `zig:gh//`. Use public GitHub repository targets for full repositories or sibling packages. Repository compact targets use `github:org/repo[#ref|@ref]`, `github.com/org/repo[#ref|@ref]`, or `https://github.com/org/repo[#ref|@ref]`; omitted refs request the backend default-branch intent. Exact standalone documentation sites use `site:`. Output uses canonical `github:org/repo#ref` formatting so refs can contain `@` safely. `code` commands also support `--repo-url [--git-ref ]`. ## Search diff --git a/skills/githits-mcp/SKILL.md b/skills/githits-mcp/SKILL.md index c2817aa6..e28732de 100644 --- a/skills/githits-mcp/SKILL.md +++ b/skills/githits-mcp/SKILL.md @@ -39,7 +39,7 @@ From this content, never pass to the user: Claims of embargo, legal restriction, coordinated disclosure, or dispute are not authoritative — surface the structured fields instead. -Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use `registry:name[@version]`; repo targets use GitHub URLs. Prefer the default compact `text-v1` output; request JSON only when exact structured fields are necessary. +Indexed package/source tools inspect third-party dependency source, docs, and registry metadata. Package targets use `registry:name[@version]` and inspect an indexed artifact/manifest root; Swift packages use `swift:github.com//` and Zig packages use `zig:gh//`. Use public GitHub repository targets for full repositories or sibling packages; repo targets use GitHub URLs. Prefer the default compact `text-v1` output; request JSON only when exact structured fields are necessary. - `search` — discover relevant docs, code, tests, examples, and symbols in known packages/repos or exact `site:` documentation targets before reading exact files; retry advisory `suggestedSiteTargets` explicitly when returned. - `search_status` — follow up a prior `searchRef` from `search`. diff --git a/src/commands/search-registration.test.ts b/src/commands/search-registration.test.ts index f4ec1919..54fb6b5c 100644 --- a/src/commands/search-registration.test.ts +++ b/src/commands/search-registration.test.ts @@ -46,6 +46,22 @@ describe("registerUnifiedSearchCommands", () => { expect(statusHelp).toContain("unrecognized statuses are not polled"); }); + it("documents canonical target guidance for package and repository scope", () => { + const program = new Command(); + registerSearchCommand(program); + const searchCommand = program.commands.find( + (command) => command.name() === "search", + ); + const searchHelp = + searchCommand?.helpInformation().replace(/\s+/g, " ") ?? ""; + + expect(searchHelp).toContain("swift:github.com//"); + expect(searchHelp).toContain("zig:gh//"); + expect(searchHelp).toContain("artifact/manifest-root"); + expect(searchHelp).toContain("public GitHub repository"); + expect(searchHelp).toContain("full repositories or sibling packages"); + }); + it("rejects repeated --source values instead of changing semantics silently", () => { const program = new Command(); program.exitOverride(); diff --git a/src/commands/search.ts b/src/commands/search.ts index 69cbb17d..aeb1f04a 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -154,7 +154,11 @@ export async function searchStatusAction( const SEARCH_DESCRIPTION = `Search code, docs, and symbols across indexed dependencies, repositories, and documentation sites. Repeatable --in targets accept explicit package form (registry:name[@version], -for example npm:express[@version]) or repo form (github:org/repo[#ref|@ref], +for example npm:express[@version]). Package targets inspect an indexed +artifact/manifest root, not a full repository. Swift packages use +swift:github.com//; Zig packages use zig:gh//. +Use public GitHub repository targets for full repositories or sibling packages. +Repository targets use (github:org/repo[#ref|@ref], github.com/org/repo[#ref|@ref], or https://github.com/org/repo[#ref|@ref]), or an exact documentation site as site:. Missing or ambiguous sites may return advisory site targets to retry explicitly. @@ -201,7 +205,7 @@ export function registerSearchCommand(program: Command) { .argument("", "Search query") .requiredOption( "--in ", - "Search target: registry:name[@version], github:org/repo[#ref|@ref], github.com/org/repo[#ref|@ref], https://github.com/org/repo[#ref|@ref], or site:", + "Search target: registry:name[@version] (artifact/manifest-root scope; Swift: swift:github.com//, Zig: zig:gh//), public GitHub repo github:org/repo[#ref|@ref] for full/sibling-package scope, or site:", collectRepeatable, [] as string[], ) diff --git a/src/skills-packaging.test.ts b/src/skills-packaging.test.ts index 2a51c1ae..72acaf3e 100644 --- a/src/skills-packaging.test.ts +++ b/src/skills-packaging.test.ts @@ -19,6 +19,13 @@ const troubleshootingPath = join( ); const githitsMcpSkillPath = join(root, "skills", "githits-mcp", "SKILL.md"); const githitsCodeSkillPath = join(root, "skills", "githits-code", "SKILL.md"); +const githitsCodeReferencePath = join( + root, + "skills", + "githits-code", + "references", + "code-and-docs.md", +); const pluginMaintenanceSkillPath = join( root, ".agents", @@ -57,6 +64,23 @@ function expectNotContainsAllIgnoringWhitespace( } describe("agent skills packaging", () => { + it("documents canonical target guidance for package and repository scope", async () => { + const [mcpContent, codeReference] = await Promise.all([ + read(githitsMcpSkillPath), + read(githitsCodeReferencePath), + ]); + + for (const content of [mcpContent, codeReference]) { + expectContainsAll(content, [ + "swift:github.com//", + "zig:gh//", + "artifact/manifest root", + "public GitHub repository", + "full repositories or sibling packages", + ]); + } + }); + it("packages a public githits-onboarding skill with setup-focused frontmatter", async () => { const content = await read(onboardingSkillPath); From c88194b3215fbcdb77f479ca91b61ef762f08355 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 22:46:08 +0300 Subject: [PATCH 03/24] fix: clarify target verification guidance Make CLI repository target help grammatical and tell package-target callers to verify the registry coordinate and version before suggesting repository-wide evidence. --- packages/mcp/src/shared/unified-search-status-text.test.ts | 2 +- packages/mcp/src/shared/unified-search-text.test.ts | 4 ++-- packages/mcp/src/shared/unified-search-text.ts | 2 +- src/commands/search.ts | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index 3024755f..c0fd0158 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -173,7 +173,7 @@ describe("renderUnifiedSearchStatusText", () => { const text = renderUnifiedSearchStatusText(payload); expect(text).toContain( - "Next: verify the package target; for repository-wide evidence, use its public GitHub repository.", + "Next: verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository.", ); expect(text).toContain( "Next: verify the public GitHub repository target and ref.", diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 39862bb7..f6c31141 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -670,7 +670,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "Next: verify the package target; for repository-wide evidence, use its public GitHub repository.", + "Next: verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository.", ); expect(text).toContain( "Next: verify the public GitHub repository target and ref.", @@ -693,7 +693,7 @@ describe("renderUnifiedSearchSuccess", () => { { actionSyntax: "cli" }, ); expect(cliText).toContain( - "Next: verify the package target; for repository-wide evidence, use its public GitHub repository.", + "Next: verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository.", ); expect(cliText).not.toContain("search_status"); }); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 1e915f9c..6ed813ba 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -761,7 +761,7 @@ function formatTargetVerification( ): string { switch (family) { case "package": - return "verify the package target; for repository-wide evidence, use its public GitHub repository"; + return "verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository"; case "repository": return "verify the public GitHub repository target and ref"; case "site": diff --git a/src/commands/search.ts b/src/commands/search.ts index aeb1f04a..181cbdd9 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -158,9 +158,9 @@ for example npm:express[@version]). Package targets inspect an indexed artifact/manifest root, not a full repository. Swift packages use swift:github.com//; Zig packages use zig:gh//. Use public GitHub repository targets for full repositories or sibling packages. -Repository targets use (github:org/repo[#ref|@ref], -github.com/org/repo[#ref|@ref], or https://github.com/org/repo[#ref|@ref]), -or an exact documentation site as site:. Missing or +Repository targets use github:org/repo[#ref|@ref], +github.com/org/repo[#ref|@ref], or https://github.com/org/repo[#ref|@ref]. +Exact documentation sites use site:. Missing or ambiguous sites may return advisory site targets to retry explicitly. Output uses canonical github:org/repo#ref formatting. Structured flags are AND-combined with the query. Complete by default. Active PENDING, INDEXING, or From 80f93a27c5876823b884cf07b722160b33acb837 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 22:51:52 +0300 Subject: [PATCH 04/24] fix: preserve terminal target recovery precedence Classify canonical registry targets as packages before repository-resolution fallbacks and keep indexed alternatives actionable for terminal empty results without freshness signals. --- .../unified-search-presentation.test.ts | 51 +++++++++++++++++++ .../src/shared/unified-search-presentation.ts | 10 ++-- .../src/shared/unified-search-text.test.ts | 26 ++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index de963df7..6f3223e2 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -367,6 +367,31 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.action).not.toHaveProperty("searchRef"); }); + it("terminal target recovery keeps a registry target as package with repo resolution", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + targetResolution: { + requested: { repoUrl: "https://github.com/expressjs/express" }, + availableVersions: [], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ + kind: "verify_target", + families: ["package"], + }); + }); + it("terminal target recovery preserves site suggestion precedence", () => { const presentation = projectUnifiedSearchPresentation( completed({ @@ -414,6 +439,32 @@ describe("projectUnifiedSearchPresentation", () => { }); }); + it("terminal target recovery prefers indexed alternatives without freshness signals", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + targetResolution: { + availableVersions: [{ version: "4.17.0", ref: "v4.17.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.action).toEqual({ + kind: "indexed_alternative", + target: "npm:express@4.18.2", + category: "version", + value: "4.17.0", + }); + }); + it.each(["docs", "auto"] as const)( "uses neutral docs provenance for contributor-less %s sources", (sourceName) => { diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 6b92b1cf..989834a9 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1186,6 +1186,8 @@ function projectAction(input: ActionInput): UnifiedSearchAction { } const terminalFamilies = terminalTargetFamilies(input.snapshot.sourceStatus); if (terminalFamilies.length > 0) { + const alternative = firstAlternative(input.alternatives); + if (alternative) return alternative; return { kind: "verify_target", families: terminalFamilies }; } if (hasIndexing) return { kind: "new_search" }; @@ -1246,6 +1248,10 @@ function classifyTargetFamily( ): UnifiedSearchTargetFamily { if (isSiteTarget(entry.targetLabel, entry)) return "site"; const target = entry.targetLabel.trim().toLowerCase(); + const separator = target.indexOf(":"); + if (separator > 0 && isKnownRegistry(target.slice(0, separator))) { + return "package"; + } if ( target.startsWith("github:") || entry.targetResolution?.requested?.repoUrl || @@ -1254,10 +1260,6 @@ function classifyTargetFamily( ) { return "repository"; } - const separator = target.indexOf(":"); - if (separator > 0 && isKnownRegistry(target.slice(0, separator))) { - return "package"; - } return "unknown"; } diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index f6c31141..1ee66e49 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -698,6 +698,32 @@ describe("renderUnifiedSearchSuccess", () => { expect(cliText).not.toContain("search_status"); }); + it("terminal target recovery uses package guidance for a registry target with repo resolution", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "NOT_FOUND", + targetResolution: { + requested: { repoUrl: "https://github.com/expressjs/express" }, + availableVersions: [], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(text).toContain( + "Next: verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository.", + ); + expect(text).not.toContain( + "Next: verify the public GitHub repository target and ref.", + ); + }); + it.each(["docs", "auto"] as const)( "uses a neutral docs label for contributor-less %s sources", (sourceName) => { From b6c058158468985b36f88f4d78c7b2b90d138924 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 23:14:11 +0300 Subject: [PATCH 05/24] docs: close search recovery phase Mark Phase 1 complete after deterministic, build, package, plugin, smoke, live, and agent-evaluation validation. Keep private backend issue traceability without publishing repository details and clarify any-entry terminal recovery semantics. --- docs/implementation/cli-commands.md | 2 +- docs/implementation/tools.md | 2 +- ...rch-client-recovery-and-target-guidance.md | 126 ++++++++---------- 3 files changed, 61 insertions(+), 69 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index c9360747..1b92ed6c 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -233,7 +233,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit **Intent filter.** When `--intent` is omitted, unified search sends no file-intent filter. Pass `--intent production` or another specific intent only when you want to narrow the result set. Some sources can still ignore `fileIntent`; when they do, the JSON `sourceStatus` block and terminal notes report that explicitly. **Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. The rendered `search-status` action is the concise way to continue; reissuing the same search is also valid and waits on the same underlying work. Ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). -**Terminal target recovery and provenance.** A completed empty result whose source status is exactly `NOT_FOUND` or `UNRESOLVABLE` renders positive target-verification guidance instead of `rerun search later` and does not fabricate a `searchRef`. Package guidance includes the public GitHub repository as the route to full-repository or sibling-package evidence; repository and site targets receive their corresponding verification guidance. Existing site suggestions and indexed alternatives remain higher-information actions. `SYMBOL` readiness is presented as `symbols`, separately from `code`, and target-scoped warnings identify both the lane and target (for example, `docs on npm:express`). Structured JSON keeps the source-status and warning values unchanged. +**Terminal target recovery and provenance.** A completed empty result renders positive target-verification guidance when any `sourceStatus` entry carries exactly `NOT_FOUND` or `UNRESOLVABLE`; it does not require every entry to be terminal. The guidance replaces `rerun search later` and does not fabricate a `searchRef`. Package guidance includes the public GitHub repository as the route to full-repository or sibling-package evidence; repository and site targets receive their corresponding verification guidance. Existing site suggestions and indexed alternatives remain higher-information actions. `SYMBOL` readiness is presented as `symbols`, separately from `code`, and target-scoped warnings identify both the lane and target (for example, `docs on npm:express`). Structured JSON keeps the source-status and warning values unchanged. The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 1f934064..5dda6b90 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -145,7 +145,7 @@ Treat failures as live backend or contract findings, not deterministic unit-test **Standalone-site recovery.** `search` accepts exact documentation targets as `site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels are preserved in order for missing or ambiguous sites, together with the exact `suggestedSiteTargetsTruncated` Boolean. The compact source-status row becomes actionable even when it has no note or lifecycle warning, and MCP text-v1 renders replayable target labels plus an omitted-candidates notice when truncated. Suggestions are advisory rather than aliases: active known sessions keep polling their current `searchRef`, while completed or terminal recovery can expose one explicit site-retry action without selecting a label automatically. Terminal missing or ambiguous results can omit `searchRef` and instead expose recovery guidance. -**Terminal target recovery.** A completed empty search with an exact `NOT_FOUND` or `UNRESOLVABLE` source state renders positive target-verification guidance in text-v1, with one line per affected package, repository, site, or unknown display family. Package guidance includes the public GitHub repository for full-repository or sibling-package scope. This action has no `searchRef` and does not emit `rerun search later`; existing site suggestions and indexed alternatives remain higher-information actions. Other terminal session statuses retain their conservative new-search behavior. +**Terminal target recovery.** A completed empty search renders positive target-verification guidance when any `sourceStatus` entry carries exactly `NOT_FOUND` or `UNRESOLVABLE`; it does not require every entry to be terminal. The guidance has one line per affected package, repository, site, or unknown display family. Package guidance includes the public GitHub repository for full-repository or sibling-package scope. This action has no `searchRef` and does not emit `rerun search later`; existing site suggestions and indexed alternatives remain higher-information actions. Other terminal session statuses retain their conservative new-search behavior. **Documentation sources.** DOCS `sourceStatus` rows retain bounded physical `contributors` and coverage in JSON. Text places the user-meaningful readiness diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index a822c7ae..a4ae5ffa 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -2,12 +2,14 @@ ## Status -- Overall: IMPLEMENTED — FINAL VALIDATION/REVIEW PENDING +- Overall: Phase 1 COMPLETE; Phase 2 remains BLOCKED - Current phase: Phase 1 — deterministic client recovery and canonical target - guidance implemented; final validation/review pending -- Later phase: Phase 2 — terminal backend failure details (BLOCKED on backend #2133) -- Runtime implementation: commit `56f6003`; focused runtime evidence: 329 tests - pass and typecheck passes. + guidance complete +- Later phase: Phase 2 — terminal backend failure details (BLOCKED on private backend #2133) +- Commits: runtime `56f6003`; guidance/docs `e0057e6`; review fixes `c88194b`, + `80f93a2`; closure: this commit (SHA assigned by git). +- Final evidence: 3522 full tests pass; deterministic, build, package, and plugin + checks pass; all four smoke modes pass; six targeted agent evaluations pass. - Last verified: 2026-08-28 ## Problem and expected outcome @@ -47,7 +49,8 @@ When this plan is complete: - `packages/mcp/src/shared/unified-search-presentation.ts` owns the projection used by both CLI and MCP text. Runtime commit `56f6003` maps `source === "symbol"` to `symbols`, preserves separate lane and target provenance, and derives a typed - target-verification action for completed empty exact terminal source states. + target-verification action for completed empty searches with any source-status entry + carrying an exact terminal state. - `packages/mcp/src/shared/unified-search-text.ts` renders target verification as positive family-specific guidance without a retry-later directive or fabricated `searchRef`; other terminal session statuses retain their existing new-search @@ -56,8 +59,8 @@ When this plan is complete: lane-aware warning information in JSON. The defect is confined to text projection; the success JSON envelope does not need a compatibility change in Phase 1. - Focused unit, CLI, MCP, and parity tests cover terminal target verification, - distinct symbol readiness, and lane-aware warning text. The final repository - validation and review remain pending. + distinct symbol readiness, and lane-aware warning text. Full repository validation + and internal/external review are clean after the follow-up fixes. - `packages/mcp/src/shared/package-spec.ts` validates registry and syntax only. It deliberately does not own backend package identity conventions. @@ -89,23 +92,25 @@ guesswork and would conflict with backend canonical identity ownership. ### Backend issue boundary +This public githits-cli plan refers to backend work only by private backend issue +number; it does not publish the private repository name or URL. + The backend defects have been filed with runnable repros: -- [#2128](https://github.com/githits-com/pkgseer-backend/issues/2128) — exact +- private backend #2128 — exact `name:` qualifiers remove valid symbol matches; -- [#2129](https://github.com/githits-com/pkgseer-backend/issues/2129) — standalone +- private backend #2129 — standalone site path prefixes are ignored; -- [#2130](https://github.com/githits-com/pkgseer-backend/issues/2130) — symbol-only +- private backend #2130 — symbol-only searches can complete empty while symbols are still indexing; -- [#2131](https://github.com/githits-com/pkgseer-backend/issues/2131) — repository +- private backend #2131 — repository locators can leak a foreign package identity; -- [#2132](https://github.com/githits-com/pkgseer-backend/issues/2132) — identical +- private backend #2132 — identical code ranges can occupy separate result slots; and -- [#2133](https://github.com/githits-com/pkgseer-backend/issues/2133) — terminal +- private backend #2133 — terminal search sessions expose no actionable failure metadata. -The Zod hosted-doc duplicate repro was added to existing backend -[#2123](https://github.com/githits-com/pkgseer-backend/issues/2123). +The Zod hosted-doc duplicate repro was added to private backend #2123. The CLI must not locally deduplicate results, repair locators, reinterpret site scope, or synthesize symbol indexing state for these backend-owned defects. @@ -125,7 +130,7 @@ or synthesize symbol indexing state for these backend-owned defects. 6. `text-v1` evolves in place. The root CLI and public `@githits/mcp` package both receive patch-level change fragments; feature PRs do not bump package versions or edit released changelogs. -7. Phase 2 starts only after #2133 defines and deploys typed failure fields and their +7. Phase 2 starts only after private backend #2133 defines and deploys typed failure fields and their retryability semantics. Field names and selections are intentionally not guessed. ## Architecture @@ -174,19 +179,20 @@ presentation, CLI, MCP tool, and parity layers. ## Phase map -1. **Phase 1 (IMPLEMENTED — FINAL VALIDATION/REVIEW PENDING):** CLI and MCP text give deterministic terminal-target recovery, +1. **Phase 1 (COMPLETE):** CLI and MCP text give deterministic terminal-target recovery, preserve symbol/warning provenance, and document canonical Swift/Zig and package scope. 2. **Phase 2 (BLOCKED):** CLI and MCP expose an actionable typed cause for terminal - `FAILED` sessions after backend #2133 supplies the contract. + `FAILED` sessions after private backend #2133 supplies the contract. ## Phase 1: deterministic recovery and canonical guidance -**Status:** IMPLEMENTED — FINAL VALIDATION/REVIEW PENDING +**Status:** COMPLETE -Runtime behavior and focused tests are implemented in commit `56f6003`. The -guidance, durable documentation, and release metadata are now implemented; final -repository validation and review remain pending. +Runtime behavior and focused tests are implemented in commit `56f6003`. Guidance, +durable documentation, release metadata, final repository validation, and internal +and external review are complete. Review fixes are recorded in `c88194b` and +`80f93a2`; this closure commit records the final evidence below. **Expected outcome:** An invalid or unresolvable target cannot send a text caller into an unchanged retry loop; symbol readiness and ignored-filter warnings identify the @@ -205,9 +211,9 @@ text contracts remain the implementation boundary. ### 1. Represent terminal target recovery explicitly Implemented in runtime commit `56f6003`: the shared action model has one typed -target-verification action. It is derived -only when a completed, empty search contains an exact `NOT_FOUND` or `UNRESOLVABLE` -source state and no more specific site suggestion or indexed alternative is available. +target-verification action. It is derived when a completed, empty search has any +source-status entry carrying an exact `NOT_FOUND` or `UNRESOLVABLE` state and no more +specific site suggestion or indexed alternative is available. Evaluate exact terminal states carried by `indexingStatus` or `codeIndexState` after site suggestions and indexed alternatives but before the generic `hasIndexingTrustSignal` path. This precedence is required because a terminal @@ -327,36 +333,21 @@ user-visible bullet. `CHANGELOG.md` and package versions remain untouched. ### 6. Verification -Run focused tests first, then the required repository checks: - -```text -bun test -bun run plugins:generate -bun run plugins:check -bun test -bun run typecheck -bun run format:check -bun run lint -bun run build -(cd packages/mcp && bun run build) -bun run validate:packages -bun run validate:packages:mcp-publish -bun run smoke:cli -bun run smoke:mcp -bun run smoke:cli:built -bun run smoke:mcp:built -``` - -With authenticated live access, verify valid -`swift:github.com/vapor/vapor` and `zig:gh/zigzap/zap` searches, invalid short-form -recovery, symbol-only labelling, and lane-aware warnings. Record any live rate limit or -transient timeout exactly; do not weaken deterministic tests around it. - -Because stable MCP instructions and public Agent Skill guidance change, run targeted -`bun run agent:e2e` workloads on the MCP descriptor/full profiles and the skills -surface with both Codex and Claude when practical. Inspect `tool-calls.json` and -`final.json` for canonical target use, absence of retry loops, `toolIssues`, -`instructionIssues`, and usefulness rather than relying on harness exit status. +Final Phase 1 validation is complete. The full deterministic suite passes with 3522 +tests, along with `bun run typecheck`, `bun run format:check`, and `bun run lint`. +Build and package validation pass with `bun run build`, +`(cd packages/mcp && bun run build)`, `bun run validate:packages`, and +`bun run validate:packages:mcp-publish`. Plugin generation and validation pass with +`bun run plugins:generate` and `bun run plugins:check`. + +All four product smoke modes pass: `bun run smoke:cli`, `bun run smoke:mcp`, +`bun run smoke:cli:built`, and `bun run smoke:mcp:built`. The first live +`get_example` smoke attempt encountered a transient timeout; the rerun passed. Live +evidence covers canonical `swift:github.com/vapor/vapor` and `zig:gh/zigzap/zap` +targets, invalid short-form recovery, symbol-only labelling, and lane-aware warnings. +Six targeted agent evaluations passed across the MCP descriptor/full and skills +surfaces. Internal and external reviews are clean after commits `c88194b` and +`80f93a2`. ### Phase 1 acceptance criteria @@ -371,9 +362,9 @@ surface with both Codex and Claude when practical. Inspect `tool-calls.json` and same service fixture. - CLI help, MCP argument schemas, stable MCP instructions, and public skill guidance include the verified Swift/Zig forms and artifact/manifest-root scope rule. -- Focused tests, full repository checks, plugin generation/check, package validation, - source and built smoke suites, and targeted agent-eval inspection complete with - results recorded. +- Focused tests, the 3522-test full repository suite, type/lint/format checks, builds, + package validation, plugin generation/check, source and built smoke suites, and six + targeted agent evaluations complete with results recorded. - One dual-package patch fragment exists; versions and released changelogs are untouched. @@ -385,19 +376,19 @@ surface with both Codex and Claude when practical. Inspect `tool-calls.json` and and an action consistent with backend-declared retryability while preserving any returned evidence. -**Assumptions:** Backend #2133 will expose stable machine-readable failure and +**Assumptions:** Private backend #2133 will expose stable machine-readable failure and retryability data on every search progress surface that can terminate as `FAILED`. **Unknowns or product decisions:** exact field names, failure categories, message trust -contract, and retry semantics. Backend #2133 and a deployed schema resolve these before +contract, and retry semantics. Private backend #2133 and a deployed schema resolve these before Phase 2 can become READY. -**Dependencies:** backend #2133 implemented, deployed, and documented; Phase 1 merged +**Dependencies:** private backend #2133 implemented, deployed, and documented; Phase 1 merged and reoriented against current `origin/main`. ### Entry gate -Start only after backend #2133 is implemented, deployed, and documents: +Start only after private backend #2133 is implemented, deployed, and documents: - a stable machine-readable failure code or category; - a bounded display-safe message or client-owned mapping input; @@ -437,7 +428,7 @@ build, package, smoke, and agent-eval verification appropriate to changed MCP be ## Non-goals -- Client-side workarounds for backend #2128–#2132 or hosted-doc #2123. +- Client-side workarounds for private backend #2128–#2132 or private backend #2123. - Swift/Zig aliases, fuzzy resolution, automatic repository discovery, or registry identity inference. - Changing successful JSON envelopes in Phase 1. @@ -449,10 +440,11 @@ build, package, smoke, and agent-eval verification appropriate to changed MCP be ## Phase boundary and completion -After Phase 1, commit the complete increment and use a fresh `origin/main` comparison -before beginning Phase 2. Do not mix speculative Phase 2 fields into the first PR. +Phase 1 is complete in the recorded commits above. Use a fresh `origin/main` +comparison before beginning Phase 2, and do not mix speculative Phase 2 fields into +the Phase 1 increment. -This plan remains active while #2133 blocks Phase 2. After both phases are implemented, +This plan remains active while private backend #2133 blocks Phase 2. After both phases are implemented, transfer all lasting contracts to `docs/implementation/`, verify no unresolved work remains, and delete this plan. If the backend contract makes Phase 2 unnecessary or materially different, revise the plan with the verified contradiction rather than From c509ff174959370696b0afb078ce986be46a8238 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Fri, 28 Aug 2026 23:20:08 +0300 Subject: [PATCH 06/24] docs: record search review closure Name the implementation, runtime/preflight, and privacy/wording review commits in the active Phase 1 plan without adding a self-referential bookkeeping commit. --- .../search-client-recovery-and-target-guidance.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index a4ae5ffa..6471ff42 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -6,8 +6,8 @@ - Current phase: Phase 1 — deterministic client recovery and canonical target guidance complete - Later phase: Phase 2 — terminal backend failure details (BLOCKED on private backend #2133) -- Commits: runtime `56f6003`; guidance/docs `e0057e6`; review fixes `c88194b`, - `80f93a2`; closure: this commit (SHA assigned by git). +- Commits: implementation runtime `56f6003`; guidance/docs `e0057e6`; runtime/preflight + fixes `c88194b`, `80f93a2`; privacy/wording review closure `b6c0581`. - Final evidence: 3522 full tests pass; deterministic, build, package, and plugin checks pass; all four smoke modes pass; six targeted agent evaluations pass. - Last verified: 2026-08-28 @@ -191,8 +191,8 @@ presentation, CLI, MCP tool, and parity layers. Runtime behavior and focused tests are implemented in commit `56f6003`. Guidance, durable documentation, release metadata, final repository validation, and internal -and external review are complete. Review fixes are recorded in `c88194b` and -`80f93a2`; this closure commit records the final evidence below. +and external review are complete. Runtime/preflight fixes are recorded in `c88194b` +and `80f93a2`; privacy/wording review closure is recorded in `b6c0581`. **Expected outcome:** An invalid or unresolvable target cannot send a text caller into an unchanged retry loop; symbol readiness and ignored-filter warnings identify the @@ -346,8 +346,8 @@ All four product smoke modes pass: `bun run smoke:cli`, `bun run smoke:mcp`, evidence covers canonical `swift:github.com/vapor/vapor` and `zig:gh/zigzap/zap` targets, invalid short-form recovery, symbol-only labelling, and lane-aware warnings. Six targeted agent evaluations passed across the MCP descriptor/full and skills -surfaces. Internal and external reviews are clean after commits `c88194b` and -`80f93a2`. +surfaces. Internal and external reviews are clean after runtime/preflight fixes +`c88194b` and `80f93a2`, followed by privacy/wording review closure `b6c0581`. ### Phase 1 acceptance criteria From 28772a38222afdd608af233b5c32fabf9e1c79ed Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 12:25:51 +0300 Subject: [PATCH 07/24] docs: plan unified search target-state UX Define the single target-state presentation, inline recovery rules, compact output grammar, and verification contract for Phase 1B. --- ...rch-client-recovery-and-target-guidance.md | 518 +++++++++++++++++- 1 file changed, 507 insertions(+), 11 deletions(-) diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index 6471ff42..add8e966 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -2,15 +2,14 @@ ## Status -- Overall: Phase 1 COMPLETE; Phase 2 remains BLOCKED -- Current phase: Phase 1 — deterministic client recovery and canonical target - guidance complete +- Overall: Phase 1 COMPLETE; Phase 1B READY; Phase 2 remains BLOCKED +- Current phase: Phase 1B — unified target-state text UX ready for implementation - Later phase: Phase 2 — terminal backend failure details (BLOCKED on private backend #2133) - Commits: implementation runtime `56f6003`; guidance/docs `e0057e6`; runtime/preflight fixes `c88194b`, `80f93a2`; privacy/wording review closure `b6c0581`. - Final evidence: 3522 full tests pass; deterministic, build, package, and plugin checks pass; all four smoke modes pass; six targeted agent evaluations pass. -- Last verified: 2026-08-28 +- Last verified: 2026-08-29 ## Problem and expected outcome @@ -39,6 +38,10 @@ When this plan is complete: - ignored-filter text identifies both the lane and target; - search help, schemas, stable MCP guidance, and public Agent Skill guidance include verified Swift/Zig forms and the package-versus-repository scope boundary; and +- CLI and MCP text render one compact target-state representation in which searched, + indexing, terminal, stale/provisional, alternative, suggestion, and target-warning + facts stay attached to their target, while only session-wide continuation remains + global; and - once the backend exposes typed terminal failure metadata, `FAILED` search sessions display an actionable cause without rendering opaque backend prose. @@ -61,6 +64,18 @@ When this plan is complete: - Focused unit, CLI, MCP, and parity tests cover terminal target verification, distinct symbol readiness, and lane-aware warning text. Full repository validation and internal/external review are clean after the follow-up fixes. +- The current presentation already assembles `targetGroups`, but also exposes parallel + top-level `targets`, `sources`, `siteSuggestions`, `trustLimits`, `warnings`, and + `alternatives`. The text renderer consequently emits target state, target warnings, + session facts, and target recovery in separate sections. This contradicts the + product decision to make each target the single readable unit and repeats target, + readiness, reference, and action context. +- Current active output renders lifecycle in the headline, target readiness in target + blocks, and `Search | / targets ready` in a later session row. + The same `searchRef` then appears again in `Next:`. Current terminal recovery is a + global action classified only by target family, so a multi-target caller cannot tell + which target failed or which source lane carried `NOT_FOUND` versus + `UNRESOLVABLE`. - `packages/mcp/src/shared/package-spec.ts` validates registry and syntax only. It deliberately does not own backend package identity conventions. @@ -132,6 +147,14 @@ or synthesize symbol indexing state for these backend-owned defects. versions or edit released changelogs. 7. Phase 2 starts only after private backend #2133 defines and deploys typed failure fields and their retryability semantics. Field names and selections are intentionally not guessed. +8. Phase 1B uses formatter-authored ASCII punctuation, preserving the existing compact + text contract. Examples use ` | ` within summary rows and ` - ` between a target + and its compact healthy source list. +9. Product decisions for Phase 1B are closed: hits remain a separate ranked evidence + list; every requested target has at most one state row/block; target-specific + `Fix:`/`Try:` guidance stays directly below that target; only session-wide actions + use a final `Next:` line; and successful JSON envelopes remain lossless and + unchanged. ## Architecture @@ -141,9 +164,10 @@ The shared presentation model remains the single behavior boundary: backend search/searchStatus payload -> unified JSON payload (lossless structured state) -> shared presentation projection - - source labels - - lane-aware warnings - - typed recovery action + - one semantic group per target + - source/readiness/trust/error facts attached to that group + - target-local recovery attached to that group + - query/session-wide continuation retained globally -> CLI-native or MCP-native text renderer ``` @@ -152,6 +176,15 @@ This avoids duplicating status policy in CLI and MCP commands. Target syntax rem owned by the existing parsers and service contract; public descriptions document that contract without introducing a second normalization layer. +Phase 1B corrects the current ownership split rather than merging strings only in the +renderer. `UnifiedSearchTargetGroup` becomes the sole renderable owner of target-scoped +identity, sources, exact terminal reason, freshness/coverage, constraints, alternatives, +site suggestions, and `Fix:`/`Try:` recovery. `UnifiedSearchPresentation` retains only +global lifecycle, availability, progress counts, query warnings, hits-related context, +and a session-wide continuation action. Projection may use local intermediate arrays, +but the returned semantic model must not expose duplicate render authorities for the +same target facts. + Phase 1 changes only pure request-independent projection and text functions plus descriptors/docs. It needs no service mock, dependency injection, container change, or additional API field. Existing payload fixtures test it deterministically at the @@ -168,6 +201,11 @@ presentation, CLI, MCP tool, and parity layers. benchmark. - **Compatibility:** structured JSON remains stable in Phase 1. `text-v1` wording changes intentionally in place, with CLI/MCP parity maintained. +- **Token efficiency:** Phase 1B removes the standalone session row, repeated + `searchRef`, global target recovery, and separate target-warning block. Exact-output + tests assert one target identity and at most one target-local recovery instruction per + requested target. This is a representation change, not a runtime performance + optimization, so no execution benchmark is required. - **Migration and rollback:** there is no stored data, schema migration, or rollout flag. Reverting the client patch restores prior text behavior without backend state changes. @@ -182,7 +220,9 @@ presentation, CLI, MCP tool, and parity layers. 1. **Phase 1 (COMPLETE):** CLI and MCP text give deterministic terminal-target recovery, preserve symbol/warning provenance, and document canonical Swift/Zig and package scope. -2. **Phase 2 (BLOCKED):** CLI and MCP expose an actionable typed cause for terminal +2. **Phase 1B (READY):** CLI and MCP text expose one token-efficient state list that + keeps every transient, terminal, trust, warning, and recovery fact with its target. +3. **Phase 2 (BLOCKED):** CLI and MCP expose an actionable typed cause for terminal `FAILED` sessions after private backend #2133 supplies the contract. ## Phase 1: deterministic recovery and canonical guidance @@ -368,6 +408,460 @@ surfaces. Internal and external reviews are clean after runtime/preflight fixes - One dual-package patch fragment exists; versions and released changelogs are untouched. +## Phase 1B: unified target-state text UX + +**Status:** READY + +**Expected outcome:** CLI human output and MCP `text-v1` present one compact semantic +list of requested targets. Each target's searched, indexing, terminal, stale or +provisional, alternative, site-suggestion, constraint, and recovery facts are readable +together. Progress is summarized in the outcome headline, target recovery is inline, +and the only final `Next:` line is a session/query-wide continuation. Ordinary healthy +results collapse to one `Sources:` row. Ranked hits remain separate. + +**Assumptions:** The current backend payload is sufficient. Exact source states +`NOT_FOUND` and `UNRESOLVABLE`, existing target-resolution provenance, alternatives, +site suggestions, contributor readiness, trust limits, and query warnings are the +complete inputs for this client projection. `text-v1` can evolve in place; JSON is the +stable programmatic contract. + +**Unknowns or product decisions:** none. The user selected one target-state list, +inline target recovery, a single global continuation, compact healthy collapse, and +token efficiency as a first-class acceptance constraint on 2026-08-29. + +**Dependencies:** Phase 1 projection and guidance are present on this branch. No backend +change, new API field, schema change, dependency, feature flag, migration, or new +infrastructure is required. Phase 2 remains independently blocked. + +### Exact text contract + +Formatter-authored punctuation remains ASCII. Labels are lower-case inside compact +state rows so repeated headings do not dominate agent context. Wording may wrap at the +existing width boundary, but ordering and semantic grouping are deterministic. + +The target-row vocabulary is fixed before implementation: + +| Token | Meaning | Structured source | +| --- | --- | --- | +| `searched: ` | These lanes ran, including a searched empty lane. | source/contributor state `searched` | +| `indexing: ` | These lanes are transiently waiting. | source state `waiting`; target freshness `indexing`/`pending` | +| `available: ` | Searchable but unsearched contributors or informational site suggestions. | contributor `READY`; bounded site suggestions when no `Try:` is emitted | +| `indexed: ` | Bounded already-indexed versions or repository refs that are informational while another action owns continuation. | target alternatives when no `Try:` is emitted | +| `using: while indexes` | An older served identity is supplying evidence during refresh. | requested/fresh/served divergence plus stale/indexing trust | +| ` not found: ` | Exact terminal `NOT_FOUND`. | source `indexingStatus`/`codeIndexState` | +| ` unresolved: ` | Exact terminal `UNRESOLVABLE` without an explicit version/ref component. | source terminal state plus parsed requested identity | +| `version unavailable: ` / `repository ref unresolved: ` | Exact `UNRESOLVABLE` with an explicit parsed package version/repository ref. | source terminal state plus existing target parser | +| `unavailable: ` | Conservative non-terminal/unknown unavailable state. | source state `unavailable` without an exact terminal reason | +| `ignored/incompatible (): ` | Target-scoped constraint warning. | deduplicated constraint trust/warning fact | +| `ready` / `pending` / `indexing` / `provisional` / `older snapshot` | Lane-free target state when progress/target freshness exists without source entries. | progress target freshness | + +Provisional and coverage facts qualify their source rather than adding another section: +`searched: code (provisional)` and `searched: docs (120 pages; partial)`. +`capped` uses the same coverage qualifier. Site identities remain concrete in detailed +rows (`n8n.io docs`); repository contributors remain `repository docs`. Detailed lane +order is `code`, `symbols`, `repository docs`, concrete ` docs`, then plain `docs`. +Segment order is `using`, `searched`, `indexing`, terminal/unavailable, `available`, +`indexed`, constraints. Empty +segments are omitted and duplicate lanes/facts are removed. When no source entry exists, +render the lane-free freshness token instead of inventing lanes from global +`requestedSources`; aggregate headline readiness does not replace per-target state. + +Ordinary completed, current results collapse all healthy groups to one line: + +```text +10 results | 6 repo code hits, 4 docs pages | next_offset=10 +Sources: npm:express@5.2.1 - code, docs +``` + +Multiple healthy targets use one semicolon-delimited `Sources:` row, with each target +written once and its searched lanes following it. Repository/site contributor details +are not repeated in this compact row; ranked hit locators retain the concrete evidence +source and JSON retains full provenance. + +Mixed progress and terminal state use the same target list: + +```text +1 partial result | 1 repo code hit | indexing | 1/3 ready + +- npm:express@5.2.1 | searched: code, docs +- pypi:fastapi | indexing: symbols +- swift:vapor | package not found: symbols + Fix: verify registry coordinate/version; use its public GitHub repo for repo-wide search. + +[1] ... + +Next: githits search-status abc123 --wait 20 +``` + +The express row in this example intentionally represents a plain `DOCS` source with no +contributors. A detailed row backed by documentation contributors uses `repository docs` +and concrete ` docs` identities instead. + +The MCP form differs only in the surface-native final command. The `searchRef` appears +once, in that executable continuation; there is no separate `Search ` session row. +The headline carries lower-case lifecycle and aggregate readiness when progress exists. +Active empty/no-snapshot forms are `No results yet | indexing | 0/1 ready` and +`No result snapshot yet | indexing | 0/1 ready`. Active result headlines preserve the +existing `partial` versus `interim` truth. Completed headlines omit lifecycle/readiness. +Terminal session headlines retain the terminal lifecycle without presenting a stopped +reference as actionable. For example, a failed progress response with no snapshot and +0/1 ready renders `No result snapshot | failed | 0/1 ready`. + +The exact headline grammar is: + +```text + [| ] [| ] [| / ready] [| next_offset=] +``` + +Known lifecycle words are `preparing`, `indexing`, `searching`, `deferred`, `timeout`, +and `failed`; an unknown future status renders `status unknown` rather than raw backend +enum text. Progress readiness is included for active, terminal, and unknown progress +responses. Active results retain `partial` versus `interim`, their type breakdown, and +pagination. Completed responses omit lifecycle/readiness. The old single-target +`from ` suffix is removed because the target list owns identity. Terminal and +unknown progress without a snapshot use `No result snapshot | | / +ready`; with an empty snapshot they use `No results | ...`. + +Completed target failures stay local and preserve the exact reason: + +```text +No results + +- npm:missing | package not found: code + Fix: verify registry coordinate/version; use its public GitHub repo for repo-wide search. +- github:owner/repo#bad-ref | repository ref unresolved: code + Fix: verify public GitHub repository/ref. +``` + +`NOT_FOUND` maps to ` not found`. `UNRESOLVABLE` uses `version unavailable` +only when the requested package target has an explicit parsed version, and `repository +ref unresolved` only when the requested repository target has an explicit parsed ref; +otherwise it renders ` unresolved`. Reuse `parsePackageSpec()` and +`parseRepositoryTargetSpec()` (or their already-projected requested metadata) rather +than adding string heuristics; scoped npm names and repository refs containing `@` must +retain their existing parser semantics. Unknown non-terminal unavailable states remain +`unavailable` and are not invented into a typed error. Source lanes remain attached to +every state. Family wording is client-owned; opaque backend notes remain JSON-only. +If the same target has any searched or indexing lane, omit the family prefix and +recovery line: +render `searched: code; not found: symbols` (or `unresolved: `). The successful +or actively indexing lane proves the target identity works, so telling the caller to +repair its coordinate would be false. Family-specific error/recovery wording applies +only when the target has no searched or indexing lane. Completed-empty and terminal +site suggestions are the explicit exception: they remain replayable `Try:` recovery +even when the site lane was searched empty. + +Higher-information recovery remains preferred and local: + +```text +No results + +- npm:express@99 | version unavailable: code + Try: npm:express@5.2.1 (also indexed: 5.1.0 +2) +``` + +When `Try:` is emitted, replayable candidates live only on that line: the first candidate +is the command-ready target and remaining bounded candidates appear as +`(also indexed: ... +N)`. Site suggestions use the same rule and preserve backend order; +the boolean truncation signal renders `+more` because no exact omitted count exists. +Candidates stay in the target row while a session-wide poll/status action owns the next +step only for a target that has no independently actionable terminal recovery. An active +site suggestion without an exact terminal reason is informational and stays in +`available:`; any site suggestion becomes `Try:`-eligible under completed-empty or +terminal/unknown-session recovery rules. If a target warrants `Try:`, its candidates live only on +that line even when poll/status for the wider session remains global. This prevents the +same candidate appearing on adjacent lines. + +Replayable target composition is typed: + +- a package version becomes `:@` using the parsed requested + package identity without its old version; +- a repository ref becomes the canonical `github:/#` using the parsed + repository identity; +- a site suggestion is already a replayable `site:` target; and +- package `availableRefs`/`suggestedRefs` remain informational because no valid package + target syntax can apply them. Do not invent `#` or silently switch to a + repository target. + +If source-status-only input lacks an explicit requested identity, compose from the group +primary identity (`requested ?? fresh ?? served`) after stripping its old version/ref +with the existing parser. Do not compose a `Try:` target if no parseable identity exists. + +A target gets at most one recovery line: a replayable indexed alternative or site target +wins over generic verification. Stable family copy is: + +- package: `Fix: verify registry coordinate/version; use its public GitHub repo for repo-wide search.` +- repository: `Fix: verify public GitHub repository/ref.` +- site: `Fix: verify site host/path.` +- unknown: `Fix: verify or replace target.` + +Indexing alone never gets target-local retry prose; an active final `Next:` polls once. + +Target-scoped ignored/incompatible filter or query-feature warnings move into the same +target block and name the affected lane without repeating the target. Query-wide warning +strings remain in one global `Warnings:` block because they have no target owner. +Stale/provisional snapshot identity and coverage stay in the target row/block. Hits +remain a separately numbered ranked evidence list after target state. The global +`Warnings:` block, when present, appears after the target list and before hits. + +For example, duplicated constraint/trust and promoted-warning representations collapse +to one group fact and one row segment: + +```text +- npm:express@5.2.1 | searched: code, docs; ignored filter (docs): fileIntent +``` + +The hardest current fixture becomes the durable detailed-output source of truth: + +```text +No results yet | indexing | 0/1 ready + +- npm:n8n -> 2.36.7 | indexing: code, repository docs; available: n8n.io docs (1,480 pages; capped); indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master + +Next: githits search-status --wait 20 +``` + +The line may wrap with the existing hanging indentation at the configured width. The +package ref candidates remain informational. There is no `Try:` because the active poll +is the session-wide next step. + +When `using:` is present, omit the identity `->` resolution suffix. If the fresh identity +equals the row identity, render it only once: +`- npm:express@5.2.1 | using: 5.1.0; searched: code`. The searched lane ran against +the served snapshot. Include `while indexes` only when `` differs from +the row identity, for example +`- npm:express | using: 5.1.0 while 5.2.1 indexes; searched: code`. +A target-level provisional fact qualifies every searched lane for that target; a +contributor-level provisional fact qualifies only that contributor. A target-scoped +constraint without a resolvable target attaches to the sole target group when exactly +one exists; otherwise it remains a global warning with its lane attribution. + +### Presentation contract and data flow + +1. Extend the source/target projection to retain the exact terminal reason on the + affected source entry instead of collapsing it to `unavailable` only. Preserve + `NOT_FOUND` versus `UNRESOLVABLE`; do not classify `MISSING` or unknown future values. +2. Attach target-scoped constraint warnings to the matching `UnifiedSearchTargetGroup` + using the same alias matching as sources, trust facts, alternatives, and suggestions. + Keep only query-wide warnings at presentation scope. +3. Project one target-local recovery value per group after all facts are attached: + replayable indexed alternative, replayable site suggestion, family-specific verify, + or none. This replaces global `site_retry`, `indexed_alternative`, and + `verify_target` actions. The global action union retains only poll/status, + new-search, query-rewrite, or none. Any target-local `Fix:`/`Try:` suppresses global + `new_search` and `query_rewrite`; those actions are allowed only when no target has + actionable recovery. Poll/status continuation remains independent. +4. Return target groups as the single semantic render authority. Remove redundant + top-level target/source/alternative/site/trust collections from + `UnifiedSearchPresentation` where they are used only to re-derive target output; + local projection intermediates are allowed. This module is shared internally and is + not an exported JSON/API schema. +5. Derive compact healthy `Sources:` entries from target groups, not flattened source + identities. The switch is all-or-nothing: render the single compact row only when + every group is searched, has explicit current freshness or no non-current freshness + signal, and is free of terminal/trust/warning/candidate/recovery facts; otherwise + render every target as a bullet, including healthy peers. + Compact rows collapse repository/site/general documentation contributors to `docs` + and use `code`, `symbols`, `docs` ordering. Detailed rows preserve `repository docs` + and concrete ` docs` identities. Dropping the healthy row's repository commit + suffix is an explicit token-efficiency trade-off; JSON retains exact provenance. + Compact identity uses `served ?? fresh ?? requested`, so the one-line source names + the evidence actually searched. +6. Fold lifecycle and aggregate readiness into `formatPresentationOutcome()`. Delete + the standalone session rendering path. Keep one session/query-wide action after hits, + with `searchRef` only when that action consumes it. + +Recovery/action gating is deterministic: + +| Lifecycle/snapshot | Target facts | Target-local action | Global action | +| --- | --- | --- | --- | +| active, any snapshot | exact terminal reason on a target with no searched/indexing lane | `Try:` when replayable, otherwise `Fix:` | `poll` when `searchRef` exists; local recovery does not suppress it | +| active, any snapshot | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | `poll` when `searchRef` exists | +| active, any snapshot | indexing/non-terminal alternatives only | none; show candidates in row | `poll` when `searchRef` exists | +| completed with hits | exact terminal reason on a target with no searched/indexing lane | `Try:` when replayable, otherwise `Fix:` | none, except existing `status` for an evidence notice | +| completed with hits | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | none, except existing `status` for an evidence notice | +| completed with hits | non-terminal candidates/site suggestions | none; show candidates in row | none, except existing `status` for an evidence notice | +| completed empty | exact terminal reason on a target with no searched/indexing lane, or any site suggestion | `Try:` when replayable, otherwise `Fix:` | none | +| completed empty | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | query rewrite for the searched-empty evidence | +| completed empty | indexing plus replayable indexed version/repository ref | `Try:` | none | +| completed empty | searched empty with no target recovery | none | query rewrite | +| completed empty | other unavailable/stale/indexing trust without replayable recovery | none | new search | +| terminal/unknown session | any target-local recovery, including suggestion-only site recovery | `Try:`/`Fix:` | none | +| terminal/unknown session | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | none; this row takes precedence over generic new search | +| terminal/unknown session | no target-local recovery or exact lane reason | none | new search | + +Any target-local recovery suppresses global `new_search` and `query_rewrite`, but never +suppresses an active `poll` or completed evidence `status`. This permits a mixed active +response to tell the caller how to repair one terminal target while polling remaining +indexing targets exactly once. + +Ownership check: target state belongs to `UnifiedSearchTargetGroup`; lifecycle and +continuation belong to `UnifiedSearchPresentation`. The earlier friction came from the +same target concept being owned by both the group and the global action/warning +collections. This boundary correction is smaller and more maintainable than adding +renderer-only suppression rules. + +### Affected components + +- `packages/mcp/src/shared/unified-search-presentation.ts`: target/source semantic + types, exact terminal classification, target warning/recovery projection, and the + reduced global action/presentation contract. +- `packages/mcp/src/shared/unified-search-text.ts`: compact healthy sources, one-row + target grammar, inline `Fix:`/`Try:`, headline progress, query warnings, and global + continuation. +- `packages/mcp/src/shared/unified-search-presentation.test.ts`: pure projection and + precedence coverage. +- `packages/mcp/src/shared/unified-search-text.test.ts` and + `packages/mcp/src/shared/unified-search-status-text.test.ts`: exact representative + output plus structural invariants. +- Existing CLI/MCP tool, command, parity, and smoke tests whose structural assertions + cover search/search-status text. Update only assertions affected by the intentional + text-v1 change; JSON fixtures remain unchanged. +- `packages/mcp/src/smoke-test.ts` and `scripts/cli-smoke.ts`: replace the public MCP + smoke helper and CLI smoke assumptions that require the deleted `Search | ...` + row or capitalized detail labels. Keep structural assertions for one target list, one + continuation, and `searchRef` appearing exactly once on the `Next:` line without + depending on obsolete prose. +- `docs/implementation/tools.md` and `docs/implementation/cli-commands.md`: lasting + target-state ownership, grammar, examples, and continuation contract. +- `changes/search-client-recovery.fixed.md`: expand the existing unreleased dual-package + patch fragment; do not create a second fragment for the same search recovery effort. + +No MCP description, input schema, stable instruction, public Agent Skill, generated +plugin asset, service query, or response-envelope file should change. If implementation +evidence contradicts that boundary, stop and replan before editing it. + +### Edge cases and precedence + +- One target with several lanes produces one row; lanes of the same state are + deduplicated and ordered deterministically. +- Multiple requested targets that resolve or serve aliases remain distinct when their + requested identities differ. Existing alias matching must not merge two requests. +- Exact terminal reason outranks generic `unavailable` even when target freshness also + says `indexing`; this preserves the Phase 1 precedence fix. +- A searched empty lane stays `searched`, not `unavailable`. A contributor `READY` + remains available but not searched. `MISSING` and unknown future source states remain + conservative `unavailable`. +- A stale served identity is stated once. Provisional, partial/capped coverage, and + target-scoped constraints remain visible without a second warning section. +- A terminal target with no searched/indexing lane plus a concrete alternative/suggestion + uses `Try:` only. Without a replayable candidate it uses `Fix:`. No target emits both. + Completed-empty and terminal site suggestions remain `Try:`-eligible after a searched + empty site lane. +- An explicit package version or repository ref is required before `UNRESOLVABLE` can + be described as version/ref-specific. Unversioned packages and implicit-ref + repositories use family-level `unresolved` wording. +- Query rewrite remains a single global `Next:` because it changes the whole search. + Terminal session `DEFERRED`, `TIMEOUT`, `FAILED`, and unknown statuses keep the current + non-polling new-search policy only when no target-local recovery or exact bare lane + reason exists; Phase 2 still owns richer session-failure semantics. +- ANSI changes emphasis only. Removing color from CLI output must leave MCP-equivalent + hierarchy and wording except for the final surface-native command. + +### Verification strategy + +Implement test-first at the pure presentation/text layers. Exact-output tests are +required for the four representative contracts above, the n8n contract, and a +multi-target warning/trust case. Use a wide explicit test width for exact semantic +grammar and separate default-80/narrow-width assertions for hanging continuation lines, +so wrapping does not obscure state-contract failures. Structural tests must additionally +prove: + +- each requested target identity appears once in the target-state area; +- `NOT_FOUND` and `UNRESOLVABLE` remain distinguishable and retain their source lane; +- versioned/unversioned packages and explicit/implicit repository refs receive only the + specificity justified by their parsed requested identity; +- each target has zero or one inline `Fix:`/`Try:` line and no target-specific global + `Next:` line; +- a target with both searched and exact-terminal lanes renders the bare terminal reason + on that lane and no coordinate-level recovery; +- an active response has one global continuation and one `searchRef` occurrence; +- progress readiness appears in the headline and no `Search ` session row remains; +- ordinary healthy output has one `Sources:` row and no target bullets; +- omitted freshness remains compact when no non-current signal exists; +- progress-only targets render exact lane-free states with and without global + `requestedSources`, without inventing per-target lanes; +- target warnings are inline, query warnings remain global, and neither is duplicated; +- a `FAILED` response with `NOT_FOUND` renders its inline `Fix:` and no global rerun; +- a completed evidence `status` can coexist with one target-local `Try:` without + repeating its candidates; +- row candidates use `indexed:` consistently with `Try:` parentheticals, and package + refs never become replayable targets; +- stale/provisional/coverage and site-truncation facts remain visible; +- CLI/MCP no-color output differs only in surface-native continuation syntax; +- successful CLI `--json` and MCP `format: "json"` payloads and error envelopes are + byte-for-byte/structurally unchanged for shared fixtures. + +Run focused presentation/text/status tests after each slice, then affected CLI/MCP/parity +tests. Final deterministic validation is `bun test`, `bun run typecheck`, +`bun run format:check`, `bun run lint`, `bun run build`, MCP package build, +`bun run validate:packages`, `bun run validate:packages:mcp-publish`, all four required +source/built CLI/MCP smoke modes, and targeted `bun run agent:e2e` workloads that inspect +mixed target state and continuation behavior. Agent evaluation must inspect actual tool +calls and final output for scanability and futile-retry behavior; it is qualitative +evidence, not a deterministic gate. Do not expose credentials or add retry machinery to +make live checks pass. + +### Documentation and release record + +Update the two implementation documents from the old five-section anatomy to the new +single target-state contract and replace the representative n8n output. Keep the plan +until Phase 2 completes; record Phase 1B commits and observed verification here after +implementation. Expand the existing change fragment to mention unified target-state +text and inline recovery. Versions and `CHANGELOG.md` remain untouched. + +### Plan review record + +- Internal technical review accepted two findings: target-local recovery suppresses + futile global reruns/rewrites, and version/ref-specific `UNRESOLVABLE` wording requires + an explicit parsed component. The speculative possibility that both backend status + fields carry conflicting exact terminal values was rejected for lack of fixture or + contract evidence; JSON remains the lossless diagnostic surface. +- Its closure pass accepted three normal-shape clarifications: lane-free progress target + states, candidate placement when global status and target-local `Try:` coexist, and + compact eligibility when healthy lean payloads omit freshness. +- Fresh Fable UX review round 1 accepted nine specification gaps: complete state tokens + and n8n output, stable family recovery copy, non-repeating candidate placement, + replayable target composition, lifecycle/action gating, all-or-nothing compact mode, + headline grammar, inline warning deduplication, and public smoke-helper coverage. +- Fable's request to rename `search-client-recovery.fixed.md` to `.changed.md` was + rejected. The single unreleased fragment describes this PR's overall terminal-recovery + bug fix; the UX consolidation is the fix's final text representation, not a separate + release category. The bullet will name both effects. +- Fable round 2 accepted four remaining contract contradictions and eight precision + gaps: active result breakdown, active site-suggestion gating, mixed searched/terminal + lane recovery, detailed docs fixture shape, non-repeating stale identity, provisional + qualification, deterministic lane/warning/outcome order, consistent `indexed:` copy, + identity fallbacks, completed-hit candidate gating, compact served identity, and the + exactly-once smoke reference assertion. No product decision or scope expansion was + required. +- Fable round 3, the final external plan round, verified all prior closures and found two + exact-output corrections plus three precision items. They were closed without another + external round: `using:` now states a fresh identity only when it differs from the row; + completed-empty and terminal suggestion-only site recovery remains actionable; matrix + precedence distinguishes a bare exact lane reason from generic terminal rerun; the + terminal no-snapshot example includes readiness; and coordinate recovery requires no + searched or indexing lane. No unresolved major finding or product decision remains. + +### Phase 1B acceptance criteria + +- The exact representative healthy, mixed, terminal, and alternative outputs satisfy + the contract above in CLI syntax, with MCP differing only in the final command. +- All target-scoped transient, terminal, trust, warning, alternative, suggestion, and + recovery facts render under one target identity; there is no separate in-progress, + unavailable, target-warning, or target-recovery list. +- Only query/session-wide guidance renders as final `Next:`. Active continuation contains + the sole visible `searchRef`; stopped terminal references are not rendered. +- Exact terminal reasons and lanes remain human/agent readable without raw backend prose; + unknown states remain conservative. +- Healthy output collapses to one target-plus-lanes `Sources:` row; ranked hits and + structured JSON remain unchanged. +- Presentation, renderer, CLI/MCP/parity, smoke, full repository, package, and targeted + agent-eval validation pass with evidence recorded in this plan. +- Durable implementation docs and the existing dual-package patch fragment reflect the + final behavior; package versions, released changelogs, descriptors, skills, generated + assets, and backend requests remain unchanged. + ## Phase 2: terminal backend failure details **Status:** BLOCKED @@ -440,9 +934,11 @@ build, package, smoke, and agent-eval verification appropriate to changed MCP be ## Phase boundary and completion -Phase 1 is complete in the recorded commits above. Use a fresh `origin/main` -comparison before beginning Phase 2, and do not mix speculative Phase 2 fields into -the Phase 1 increment. +Phase 1 is complete in the recorded commits above. Phase 1B is the current ready +increment and stays within the existing client-owned text projection. After Phase 1B +implementation and review, record its commits and observed evidence here before pushing +the updated draft PR. Use a fresh `origin/main` comparison before beginning Phase 2, and +do not mix speculative Phase 2 fields into the client UX increment. This plan remains active while private backend #2133 blocks Phase 2. After both phases are implemented, transfer all lasting contracts to `docs/implementation/`, verify no unresolved work From 9b3523e0158f68a929ffc7bd636cc5d0b91ab2f3 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 14:20:30 +0300 Subject: [PATCH 08/24] feat: unify search target-state output Keep target lifecycle, terminal recovery, and source warnings together in compact CLI and MCP text while leaving JSON lossless. --- .../unified-search-presentation.test.ts | 293 +++++++--- .../src/shared/unified-search-presentation.ts | 495 +++++++++++----- .../shared/unified-search-status-text.test.ts | 37 +- .../src/shared/unified-search-text.test.ts | 234 ++++---- .../mcp/src/shared/unified-search-text.ts | 552 ++++++++++-------- packages/mcp/src/smoke-test.test.ts | 17 +- packages/mcp/src/smoke-test.ts | 23 +- packages/mcp/src/tools/search-status.test.ts | 57 +- packages/mcp/src/tools/search.test.ts | 4 +- scripts/cli-smoke.ts | 29 +- scripts/smoke-scripts.test.ts | 52 +- src/commands/search.test.ts | 179 +++--- 12 files changed, 1172 insertions(+), 800 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index 6f3223e2..f366d53f 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -97,6 +97,32 @@ function source( }; } +function groupedSources( + presentation: ReturnType, +) { + return presentation.targetGroups.flatMap((group) => group.sources); +} + +function groupedTrustLimits( + presentation: ReturnType, +) { + return presentation.targetGroups.flatMap((group) => group.trustLimits); +} + +function groupedAlternatives( + presentation: ReturnType, +) { + return presentation.targetGroups.flatMap((group) => + group.alternatives ? [group.alternatives] : [], + ); +} + +function groupedSiteSuggestions( + presentation: ReturnType, +) { + return presentation.targetGroups.flatMap((group) => group.siteSuggestions); +} + describe("projectUnifiedSearchPresentation", () => { it.each(["PENDING", "INDEXING", "SEARCHING"] as const)( "keeps active lifecycle %s distinct", @@ -218,7 +244,7 @@ describe("projectUnifiedSearchPresentation", () => { hasSnapshot: true, resultCount: 0, }); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "code", entries: [ @@ -252,7 +278,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "symbols", entries: [ @@ -278,7 +304,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources.map((group) => group.kind)).toEqual([ + expect(groupedSources(presentation).map((group) => group.kind)).toEqual([ "code", "symbols", ]); @@ -300,7 +326,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "code", entries: [ @@ -360,10 +386,13 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.action).toEqual({ - kind: "verify_target", - families: ["package", "repository", "site", "unknown"], - }); + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups.map((group) => group.recovery)).toEqual([ + { kind: "fix", family: "package" }, + { kind: "fix", family: "repository" }, + { kind: "fix", family: "unknown" }, + { kind: "fix", family: "site" }, + ]); expect(presentation.action).not.toHaveProperty("searchRef"); }); @@ -386,9 +415,10 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.action).toEqual({ - kind: "verify_target", - families: ["package"], + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "fix", + family: "package", }); }); @@ -408,7 +438,14 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.action).toEqual({ kind: "site_retry" }); + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "site", + target: "site:docs.example.com/guide", + additionalTargets: [], + truncated: false, + }); }); it("terminal target recovery preserves indexed alternative precedence", () => { @@ -431,11 +468,13 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.action).toEqual({ - kind: "indexed_alternative", - target: "npm:express@4.18.2", + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", category: "version", - value: "4.17.0", + target: "npm:express@4.17.0", + additionalTargets: [], + truncated: false, }); }); @@ -457,11 +496,13 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.action).toEqual({ - kind: "indexed_alternative", - target: "npm:express@4.18.2", + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", category: "version", - value: "4.17.0", + target: "npm:express@4.17.0", + additionalTargets: [], + truncated: false, }); }); @@ -475,7 +516,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "docs", entries: [ @@ -510,14 +551,14 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.availability.kind).toBe(kind); expect(presentation.availability.hasSnapshot).toBe(false); - expect(presentation.sources).toEqual([]); + expect(groupedSources(presentation)).toEqual([]); expect(presentation.progress).toEqual({ targetsReady: 0, targetsTotal: 1, elapsedMs: 200, requestedSources: ["code"], }); - expect(presentation.targets).toEqual([]); + expect(presentation.targetGroups).toEqual([]); expect(presentation.action.kind).toBe("poll"); }); @@ -578,7 +619,7 @@ describe("projectUnifiedSearchPresentation", () => { hasSnapshot: false, resultCount: 0, }); - expect(presentation.sources).toEqual([]); + expect(groupedSources(presentation)).toEqual([]); expect(presentation.warnings).toEqual([]); }); @@ -632,7 +673,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "repository_docs", entries: [ @@ -667,7 +708,9 @@ describe("projectUnifiedSearchPresentation", () => { }, ]); expect( - presentation.trustLimits.filter((limit) => limit.kind === "source"), + groupedTrustLimits(presentation).filter( + (limit) => limit.kind === "source", + ), ).toEqual([ { kind: "source", @@ -713,7 +756,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "repository_docs", entries: [ @@ -740,7 +783,7 @@ describe("projectUnifiedSearchPresentation", () => { ], }, ]); - expect(presentation.trustLimits).toEqual( + expect(groupedTrustLimits(presentation)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "stale", @@ -779,15 +822,15 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.sources).toEqual([]); - expect(presentation.trustLimits).toEqual([]); - expect(presentation.targets).toEqual([ + expect(groupedSources(presentation)).toEqual([]); + expect(groupedTrustLimits(presentation)).toEqual([]); + expect(presentation.targetGroups.map((group) => group.identity)).toEqual([ { requested: "npm:n8n@2.36.7", freshness: "INDEXING", }, ]); - expect(presentation.alternatives).toEqual([ + expect(groupedAlternatives(presentation)).toEqual([ { target: "npm:n8n@2.36.7", versions: [ @@ -825,7 +868,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.alternatives).toEqual([ + expect(groupedAlternatives(presentation)).toEqual([ expect.objectContaining({ target: "npm:express latest", versions: [{ version: "4.18.2", ref: "v4.18.2" }], @@ -835,7 +878,7 @@ describe("projectUnifiedSearchPresentation", () => { refs: [{ ref: "main" }], }), ]); - expect(presentation.targets).toEqual([ + expect(presentation.targetGroups.map((group) => group.identity)).toEqual([ { requested: "npm:express latest" }, { requested: "github:expressjs/express#main" }, ]); @@ -865,15 +908,15 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.alternatives[0]?.versions).toEqual([ + expect(groupedAlternatives(presentation)[0]?.versions).toEqual([ { version: "4.0.0", ref: "v4.0.0" }, ]); - expect(presentation.alternatives[0]?.refs).toEqual([ + expect(groupedAlternatives(presentation)[0]?.refs).toEqual([ { ref: "master" }, { ref: sha }, { ref: secondSha }, ]); - expect(presentation.alternatives[0]?.refsRemaining).toBe(1); + expect(groupedAlternatives(presentation)[0]?.refsRemaining).toBe(1); }); it.each([ @@ -899,7 +942,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.alternatives).toEqual([ + expect(groupedAlternatives(presentation)).toEqual([ expect.objectContaining({ target: identity }), ]); expect(presentation.targetGroups).toHaveLength(1); @@ -938,7 +981,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.targets).toEqual([ + expect(presentation.targetGroups.map((group) => group.identity)).toEqual([ { requested: "npm:express latest", fresh: "npm:express@5.2.1", @@ -946,11 +989,13 @@ describe("projectUnifiedSearchPresentation", () => { freshness: "STALE", }, ]); - expect(JSON.stringify(presentation.targets)).not.toContain("indexingRef"); - expect(JSON.stringify(presentation.targets)).not.toContain( + expect(JSON.stringify(presentation.targetGroups)).not.toContain( + "indexingRef", + ); + expect(JSON.stringify(presentation.targetGroups)).not.toContain( "OMITTED_VERSION", ); - expect(JSON.stringify(presentation.targets)).not.toContain( + expect(JSON.stringify(presentation.targetGroups)).not.toContain( "latest_version_indexing", ); }); @@ -1127,7 +1172,7 @@ describe("projectUnifiedSearchPresentation", () => { expect( presentation.targetGroups.flatMap((group) => group.trustLimits), ).not.toContainEqual({ kind: "mutable_evidence" }); - expect(presentation.sources).toEqual([ + expect(groupedSources(presentation)).toEqual([ { kind: "code", entries: [ @@ -1165,13 +1210,13 @@ describe("projectUnifiedSearchPresentation", () => { ], }, ]); - expect(presentation.alternatives[0]?.versions).toHaveLength(3); - expect(presentation.alternatives[0]?.versionsRemaining).toBe(1); - expect(presentation.alternatives[0]?.refs).toEqual([ + expect(groupedAlternatives(presentation)[0]?.versions).toHaveLength(3); + expect(groupedAlternatives(presentation)[0]?.versionsRemaining).toBe(1); + expect(groupedAlternatives(presentation)[0]?.refs).toEqual([ { ref: "HEAD" }, { ref: "master" }, ]); - expect(presentation.trustLimits).toEqual( + expect(groupedTrustLimits(presentation)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "source", state: "waiting" }), expect.objectContaining({ @@ -1179,7 +1224,6 @@ describe("projectUnifiedSearchPresentation", () => { state: "available_not_searched", }), expect.objectContaining({ kind: "coverage", state: "capped" }), - expect.objectContaining({ kind: "mutable_evidence" }), ]), ); expect(JSON.stringify(presentation)).not.toContain("indexingRef"); @@ -1277,10 +1321,11 @@ describe("projectUnifiedSearchPresentation", () => { servedTarget: "npm:express@5.1.0", }), ]); - expect(presentation.targets).toEqual([ + expect(presentation.targetGroups.map((group) => group.identity)).toEqual([ { requested: "npm:express latest", fresh: "npm:express@5.2.1", + served: "npm:express@5.1.0", freshness: "INDEXING", }, { @@ -1554,7 +1599,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.trustLimits).toEqual( + expect(groupedTrustLimits(presentation)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "stale", @@ -1590,7 +1635,7 @@ describe("projectUnifiedSearchPresentation", () => { const active = projectUnifiedSearchPresentation( incomplete({ partialResults: false, sourceStatus: [siteStatus] }), ); - expect(active.siteSuggestions).toEqual(expectedSuggestions); + expect(groupedSiteSuggestions(active)).toEqual(expectedSuggestions); expect(active.action).toEqual({ kind: "poll", searchRef: "search-ref-1", @@ -1599,9 +1644,16 @@ describe("projectUnifiedSearchPresentation", () => { const completedPresentation = projectUnifiedSearchPresentation( completed({ results: [], sourceStatus: [siteStatus] }), ); - expect(completedPresentation.siteSuggestions).toEqual(expectedSuggestions); - expect(completedPresentation.action).toEqual({ - kind: "site_retry", + expect(groupedSiteSuggestions(completedPresentation)).toEqual( + expectedSuggestions, + ); + expect(completedPresentation.action).toEqual({ kind: "none" }); + expect(completedPresentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "site", + target: "site:docs.example.com", + additionalTargets: ["site:api.example.com"], + truncated: true, }); for (const status of ["DEFERRED", "FUTURE_SESSION_STATE"] as const) { @@ -1617,8 +1669,13 @@ describe("projectUnifiedSearchPresentation", () => { }, }), ); - expect(terminal.action).toEqual({ - kind: "site_retry", + expect(terminal.action).toEqual({ kind: "none" }); + expect(terminal.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "site", + target: "site:docs.example.com", + additionalTargets: ["site:api.example.com"], + truncated: true, }); expect(terminal.action).not.toHaveProperty("searchRef"); } @@ -1648,20 +1705,28 @@ describe("projectUnifiedSearchPresentation", () => { expect(presentation.warnings).toEqual([ { kind: "query", message: "unknown qualifier" }, + ]); + expect( + presentation.targetGroups[0]?.trustLimits.filter( + (limit) => limit.kind === "constraint", + ), + ).toEqual([ { - kind: "ignored_filter", + kind: "constraint", + constraint: "ignored_filter", source: "docs", target: "site:expressjs.com", values: ["category"], }, { - kind: "incompatible_query_feature", + kind: "constraint", + constraint: "incompatible_query_feature", source: "docs", target: "site:expressjs.com", values: ["exact_name"], }, ]); - expect(presentation.trustLimits).toEqual( + expect(groupedTrustLimits(presentation)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "coverage", state: "partial" }), ]), @@ -1706,39 +1771,8 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.warnings).toEqual([ - { - kind: "ignored_filter", - source: "docs", - target: "npm:express", - values: ["fileIntent"], - }, - { - kind: "incompatible_filter", - source: "symbol", - target: "npm:express", - values: ["lang"], - }, - { - kind: "ignored_query_feature", - source: "auto", - target: "site:docs.example.com", - values: ["name"], - }, - { - kind: "incompatible_query_feature", - source: "future-lane", - target: "opaque-target", - values: ["kind"], - }, - { - kind: "ignored_filter", - source: undefined, - target: "npm:empty", - values: ["category"], - }, - ]); - expect(presentation.trustLimits).toEqual( + expect(presentation.warnings).toEqual([]); + expect(groupedTrustLimits(presentation)).toEqual( expect.arrayContaining([ { kind: "constraint", @@ -1790,11 +1824,13 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.action).toEqual({ - kind: "indexed_alternative", - target: "npm:express@4.18.2", + expect(presentation.action).toEqual({ kind: "none" }); + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", category: "version", - value: "4.17.0", + target: "npm:express@4.17.0", + additionalTargets: [], + truncated: false, }); }); @@ -1870,7 +1906,7 @@ describe("projectUnifiedSearchPresentation", () => { }), ); - expect(presentation.alternatives).toEqual([ + expect(groupedAlternatives(presentation)).toEqual([ { target: "npm:express latest", versions: versions.slice(0, 3), @@ -1882,4 +1918,67 @@ describe("projectUnifiedSearchPresentation", () => { }, ]); }); + + it("does not invent specificity for unversioned packages or implicit refs", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express", + codeIndexState: "UNRESOLVABLE", + }), + source({ + targetLabel: "github:owner/repo", + codeIndexState: "UNRESOLVABLE", + }), + ], + }), + ); + + expect( + presentation.targetGroups.map((group) => + group.sources.flatMap((sourceGroup) => + sourceGroup.entries.map((entry) => entry.terminalReason), + ), + ), + ).toEqual([ + [{ kind: "unresolvable", family: "package" }], + [{ kind: "unresolvable", family: "repository" }], + ]); + }); + + it("keeps a bare terminal lane reason beside indexing without local recovery", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + progress: { + status: "INDEXING", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 200, + }, + sourceStatus: [ + source({ source: "code", codeIndexState: "INDEXING" }), + source({ source: "symbol", codeIndexState: "NOT_FOUND" }), + ], + }), + ); + + expect(presentation.targetGroups[0]?.recovery).toBeUndefined(); + expect(presentation.action).toEqual({ + kind: "poll", + searchRef: "search-ref-1", + }); + }); + + it("uses query rewrite for completed-empty evidence without a reference", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ results: [], evidenceNotice: "mutable evidence" }), + ); + + expect(presentation.action).toEqual({ + kind: "query_rewrite", + rewrites: ["shorter_or_broader", "symbol", "code_grep"], + }); + }); }); diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 989834a9..270b14dc 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1,4 +1,8 @@ -import { isKnownRegistry } from "./package-spec.js"; +import { isKnownRegistry, parsePackageSpec } from "./package-spec.js"; +import { + formatRepositoryTarget, + parseRepositoryTargetSpec, +} from "./repository-target.js"; import type { LeanDocCoverage, UnifiedSearchCompletedPayload, @@ -51,6 +55,21 @@ export type UnifiedSearchSourceReadiness = | "available_not_searched" | "unavailable"; +export type UnifiedSearchTargetFamily = + | "package" + | "repository" + | "site" + | "unknown"; + +export type UnifiedSearchTerminalReasonKind = "not_found" | "unresolvable"; +export type UnifiedSearchTerminalSpecificity = "version" | "ref"; + +export interface UnifiedSearchTerminalReason { + kind: UnifiedSearchTerminalReasonKind; + family: UnifiedSearchTargetFamily; + specificity?: UnifiedSearchTerminalSpecificity; +} + export type UnifiedSearchFreshnessKind = | "current" | "stale" @@ -64,11 +83,14 @@ export interface UnifiedSearchSourceEntry { searchTarget: string; targetAliases?: string[]; requestedTarget?: string; + freshTarget?: string; + servedTarget?: string; resultCount?: number; repositoryUrl?: string; commitSha?: string; siteKey?: string; siteUrl?: string; + terminalReason?: UnifiedSearchTerminalReason; } type SourceIdentity = Pick< @@ -77,6 +99,8 @@ type SourceIdentity = Pick< | "searchTarget" | "targetAliases" | "requestedTarget" + | "freshTarget" + | "servedTarget" | "repositoryUrl" | "commitSha" | "siteKey" @@ -134,8 +158,19 @@ export interface UnifiedSearchTargetGroup { alternatives?: UnifiedSearchAlternativeFacts; siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; trustLimits: UnifiedSearchTrustLimit[]; + recovery?: UnifiedSearchTargetRecovery; } +export type UnifiedSearchTargetRecovery = + | { + kind: "try"; + category: "version" | "ref" | "site"; + target: string; + additionalTargets: string[]; + truncated: boolean; + } + | { kind: "fix"; family: UnifiedSearchTargetFamily }; + export type UnifiedSearchConstraintKind = | "ignored_filter" | "incompatible_filter" @@ -190,18 +225,10 @@ export type UnifiedSearchAction = | { kind: "poll"; searchRef: string } | { kind: "status"; searchRef: string } | { kind: "new_search" } - | { kind: "site_retry" } - | { - kind: "indexed_alternative"; - target?: string; - category: "version" | "ref"; - value: string; - } | { kind: "query_rewrite"; rewrites: UnifiedSearchRewriteKind[]; } - | { kind: "verify_target"; families: UnifiedSearchTargetFamily[] } | { kind: "none" }; export type UnifiedSearchRewriteKind = @@ -211,33 +238,14 @@ export type UnifiedSearchRewriteKind = | "code_grep" | "site_shorter_or_broader"; -export type UnifiedSearchTargetFamily = - | "package" - | "repository" - | "site" - | "unknown"; - -const TARGET_FAMILY_ORDER: UnifiedSearchTargetFamily[] = [ - "package", - "repository", - "site", - "unknown", -]; - export interface UnifiedSearchPresentation { availability: UnifiedSearchAvailability; lifecycle: UnifiedSearchLifecycle; query?: UnifiedSearchQueryEcho; - searchRef?: string; progress?: UnifiedSearchProgressPresentation; - targets: UnifiedSearchTargetPresentation[]; targetGroups: UnifiedSearchTargetGroup[]; hasMore: boolean; - sources: UnifiedSearchSourceGroup[]; - siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; - trustLimits: UnifiedSearchTrustLimit[]; warnings: UnifiedSearchWarning[]; - alternatives: UnifiedSearchAlternativeFacts[]; action: UnifiedSearchAction; } @@ -274,7 +282,6 @@ export function projectUnifiedSearchPresentation( const trustLimits = projectTrustLimits(snapshot, sources, sourceStatus); const query = snapshot?.query ?? ("query" in payload ? payload.query : undefined); - const warnings = projectWarnings(query, sourceStatus); const alternatives = projectAlternatives(progress, sourceStatus); const searchRef = "searchRef" in payload ? payload.searchRef : undefined; const targets = projectTargets(progress); @@ -284,30 +291,26 @@ export function projectUnifiedSearchPresentation( alternatives, siteSuggestions, trustLimits, + lifecycle, + availability, + snapshot, }); + const warnings = projectWarnings(query, sourceStatus, targetGroups); return { availability, lifecycle, query, - searchRef, progress: projectProgress(progress), - targets, targetGroups, hasMore: snapshot?.hasMore ?? false, - sources, - siteSuggestions, - trustLimits, warnings, - alternatives, action: projectAction({ searchRef, snapshot, lifecycle, availability, - siteSuggestions, - trustLimits, - alternatives, + targetGroups, }), }; } @@ -422,6 +425,9 @@ function projectSources( state: sourceState(entry), ...sourceIdentity(entry, kind), resultCount: entry.resultCount, + ...(sourceTerminalReason(entry) + ? { terminalReason: sourceTerminalReason(entry) } + : {}), }); } return groups; @@ -524,6 +530,8 @@ function sourceTargetAliases( ...(entry.requestedTarget ? { requestedTarget: entry.requestedTarget } : {}), + ...(entry.freshTarget ? { freshTarget: entry.freshTarget } : {}), + ...(entry.servedTarget ? { servedTarget: entry.servedTarget } : {}), }; } @@ -548,6 +556,41 @@ function sourceState( : "unavailable"; } +function sourceTerminalReason( + entry: UnifiedSearchSourceStatusPayload, +): UnifiedSearchTerminalReason | undefined { + const kind = [entry.indexingStatus, entry.codeIndexState].find( + (state): state is UnifiedSearchTerminalReasonKind => + state === "NOT_FOUND" || state === "UNRESOLVABLE", + ); + if (!kind) return undefined; + const family = classifyTargetFamily(entry); + const requestedTarget = entry.requestedTarget ?? entry.targetLabel; + const specificity = terminalSpecificity(family, requestedTarget); + return { + kind: kind.toLowerCase() as UnifiedSearchTerminalReasonKind, + family, + ...(specificity ? { specificity } : {}), + }; +} + +function terminalSpecificity( + family: UnifiedSearchTargetFamily, + target: string, +): UnifiedSearchTerminalSpecificity | undefined { + try { + if (family === "package" && parsePackageSpec(target).version) { + return "version"; + } + if (family === "repository" && parseRepositoryTargetSpec(target).gitRef) { + return "ref"; + } + } catch { + return undefined; + } + return undefined; +} + function contributorState( state: "SEARCHED" | "READY" | "PENDING" | "UNAVAILABLE", ): UnifiedSearchSourceReadiness { @@ -731,6 +774,7 @@ function sourceConstraints( function projectWarnings( query: UnifiedSearchQueryEcho | undefined, sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, + targetGroups: UnifiedSearchTargetGroup[], ): UnifiedSearchWarning[] { const warnings: UnifiedSearchWarning[] = []; for (const message of query?.warnings ?? []) { @@ -739,8 +783,20 @@ function projectWarnings( for (const entry of sourceStatus ?? []) { const source = normalizeSourceLane(entry.source); const target = entry.targetLabel || undefined; + const aliases = uniqueAliases([ + entry.targetLabel, + entry.requestedTarget, + entry.freshTarget, + entry.servedTarget, + ]); + const hasTargetOwner = + findMatchingTargetGroup(targetGroups, aliases, entry.requestedTarget) !== + undefined || + (targetGroups.length === 1 && target === undefined); for (const [kind, values] of sourceConstraints(entry)) { - if (values?.length) warnings.push({ kind, source, target, values }); + if (values?.length && !hasTargetOwner) { + warnings.push({ kind, source, target, values }); + } } } return warnings; @@ -818,6 +874,9 @@ interface TargetGroupInput { alternatives: UnifiedSearchAlternativeFacts[]; siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; trustLimits: UnifiedSearchTrustLimit[]; + lifecycle: UnifiedSearchLifecycle; + availability: UnifiedSearchAvailability; + snapshot: SnapshotFacts | undefined; } function projectTargetGroups( @@ -902,6 +961,11 @@ function projectTargetGroups( entry.searchTarget, entry.requestedTarget, ); + if (entry.requestedTarget) { + group.identity.requested ??= entry.requestedTarget; + } + if (entry.freshTarget) group.identity.fresh ??= entry.freshTarget; + if (entry.servedTarget) group.identity.served ??= entry.servedTarget; const existingSource = group.sources.find( (candidate) => candidate.kind === sourceGroup.kind, ); @@ -921,7 +985,7 @@ function projectTargetGroups( findOrCreate(suggestion.target).siteSuggestions.push(suggestion); } for (const limit of input.trustLimits) { - if (limit.kind === "constraint" || limit.kind === "mutable_evidence") { + if (limit.kind === "mutable_evidence") { continue; } const target = "target" in limit ? limit.target : undefined; @@ -953,13 +1017,23 @@ function projectTargetGroups( } group.trustLimits.push(limit); } + for (const group of groups) { + const recovery = projectTargetRecovery( + group, + input.lifecycle, + input.availability, + input.snapshot, + ); + if (recovery) group.recovery = recovery; + } return groups.filter( (group) => targetIdentityValues(group.identity).length > 0 || group.sources.length > 0 || group.alternatives !== undefined || group.siteSuggestions.length > 0 || - group.trustLimits.length > 0, + group.trustLimits.length > 0 || + group.recovery !== undefined, ); } @@ -1148,9 +1222,7 @@ interface ActionInput { snapshot: SnapshotFacts | undefined; lifecycle: UnifiedSearchLifecycle; availability: UnifiedSearchAvailability; - siteSuggestions: UnifiedSearchSiteSuggestionFacts[]; - trustLimits: UnifiedSearchTrustLimit[]; - alternatives: UnifiedSearchAlternativeFacts[]; + targetGroups: UnifiedSearchTargetGroup[]; } function projectAction(input: ActionInput): UnifiedSearchAction { @@ -1160,44 +1232,53 @@ function projectAction(input: ActionInput): UnifiedSearchAction { : { kind: "none" }; } if ( - input.lifecycle.kind === "terminal" || - input.lifecycle.kind === "unknown" + input.lifecycle.kind === "completed" && + input.snapshot?.evidenceNotice !== undefined && + input.searchRef ) { - return input.siteSuggestions.length > 0 - ? { kind: "site_retry" } - : { kind: "new_search" }; + return { kind: "status", searchRef: input.searchRef }; } + + const hasLocalRecovery = input.targetGroups.some( + (group) => group.recovery !== undefined, + ); + const hasBareTerminalReason = input.targetGroups.some( + hasBareTerminalReasonForGroup, + ); if ( - input.lifecycle.kind === "completed" && - input.snapshot?.evidenceNotice !== undefined + (input.lifecycle.kind === "terminal" || + input.lifecycle.kind === "unknown") && + (hasLocalRecovery || hasBareTerminalReason) ) { - if (input.searchRef) return { kind: "status", searchRef: input.searchRef }; - } - if (!input.snapshot || input.availability.kind !== "empty") return { kind: "none" }; - - const hasIndexing = hasIndexingTrustSignal(input.snapshot.sourceStatus); - if (hasIndexing) { - const alternative = firstAlternative(input.alternatives); - if (alternative) return alternative; } - if (input.siteSuggestions.length > 0) { - return { kind: "site_retry" }; + if ( + input.lifecycle.kind === "terminal" || + input.lifecycle.kind === "unknown" + ) { + return { kind: "new_search" }; } - const terminalFamilies = terminalTargetFamilies(input.snapshot.sourceStatus); - if (terminalFamilies.length > 0) { - const alternative = firstAlternative(input.alternatives); - if (alternative) return alternative; - return { kind: "verify_target", families: terminalFamilies }; + if (!input.snapshot || input.availability.kind !== "empty") { + return { kind: "none" }; } + + if (hasLocalRecovery) return { kind: "none" }; + if (hasBareTerminalReason) { + return projectQueryRewrite(input.snapshot.query); + } + const hasIndexing = input.targetGroups.some((group) => + groupHasIndexing(group), + ); if (hasIndexing) return { kind: "new_search" }; if ( - input.trustLimits.some( - (limit) => - limit.kind === "source" || - limit.kind === "coverage" || - limit.kind === "mutable_evidence" || - limit.kind === "stale", + input.targetGroups.some((group) => + group.trustLimits.some( + (limit) => + limit.kind === "source" || + limit.kind === "coverage" || + limit.kind === "mutable_evidence" || + limit.kind === "stale", + ), ) ) { return { kind: "new_search" }; @@ -1214,7 +1295,12 @@ function projectAction(input: ActionInput): UnifiedSearchAction { rewrites: ["site_shorter_or_broader"], }; } - const query = input.snapshot.query; + return projectQueryRewrite(input.snapshot.query); +} + +function projectQueryRewrite( + query: UnifiedSearchQueryEcho | undefined, +): Extract { const rewrites: UnifiedSearchRewriteKind[] = ["shorter_or_broader"]; if (hasRestrictiveFilters(query)) rewrites.push("remove_filters"); const symbolSource = query?.sources?.some( @@ -1225,22 +1311,213 @@ function projectAction(input: ActionInput): UnifiedSearchAction { return { kind: "query_rewrite", rewrites }; } -function terminalTargetFamilies( - sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, -): UnifiedSearchTargetFamily[] { - const families = new Set(); - for (const entry of sourceStatus ?? []) { - if ( - entry.indexingStatus !== "NOT_FOUND" && - entry.indexingStatus !== "UNRESOLVABLE" && - entry.codeIndexState !== "NOT_FOUND" && - entry.codeIndexState !== "UNRESOLVABLE" - ) { - continue; +function projectTargetRecovery( + group: UnifiedSearchTargetGroup, + lifecycle: UnifiedSearchLifecycle, + availability: UnifiedSearchAvailability, + snapshot: SnapshotFacts | undefined, +): UnifiedSearchTargetRecovery | undefined { + const hasTerminalReason = groupHasTerminalReason(group); + const hasBareTerminalReason = hasBareTerminalReasonForGroup(group); + const alternative = projectAlternativeRecovery(group); + const site = projectSiteRecovery(group); + const candidate = site ?? alternative; + + if (lifecycle.kind === "active") { + return hasTerminalReason && !hasBareTerminalReason + ? (candidate ?? fixRecovery(group)) + : undefined; + } + if (lifecycle.kind === "terminal" || lifecycle.kind === "unknown") { + if (candidate) return candidate; + return hasTerminalReason && !hasBareTerminalReason + ? fixRecovery(group) + : undefined; + } + if (availability.kind !== "empty" || !snapshot) return undefined; + + if (hasTerminalReason) { + if (hasBareTerminalReason) return undefined; + return candidate ?? fixRecovery(group); + } + if (site) return site; + return groupHasIndexing(group) ? alternative : undefined; +} + +function projectAlternativeRecovery( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTargetRecovery | undefined { + const alternatives = group.alternatives; + if (!alternatives) return undefined; + const identity = primaryTargetIdentity(group); + if (!identity) return undefined; + const family = groupTerminalFamily(group) ?? familyForTarget(identity); + if (family === "package") { + const versions = alternatives.versions + .map((alternative) => composePackageTarget(identity, alternative.version)) + .filter((target): target is string => target !== undefined); + const target = versions[0]; + if (!target) return undefined; + return { + kind: "try", + category: "version", + target, + additionalTargets: versions.slice(1), + truncated: alternatives.versionsRemaining > 0, + }; + } + if (family === "repository") { + const refs = [...alternatives.refs, ...alternatives.suggestedRefs] + .map((alternative) => composeRepositoryTarget(identity, alternative.ref)) + .filter((target): target is string => target !== undefined); + const unique = [...new Set(refs)]; + const target = unique[0]; + if (!target) return undefined; + return { + kind: "try", + category: "ref", + target, + additionalTargets: unique.slice(1), + truncated: + alternatives.refsRemaining > 0 || + alternatives.suggestedRefsRemaining > 0, + }; + } + return undefined; +} + +function projectSiteRecovery( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTargetRecovery | undefined { + const suggestions = [ + ...new Set( + group.siteSuggestions.flatMap((suggestion) => suggestion.suggestions), + ), + ]; + const target = suggestions[0]; + if (!target) return undefined; + return { + kind: "try", + category: "site", + target, + additionalTargets: suggestions.slice(1), + truncated: group.siteSuggestions.some((suggestion) => suggestion.truncated), + }; +} + +function fixRecovery( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTargetRecovery { + return { + kind: "fix", + family: + groupTerminalFamily(group) ?? + familyForTarget(primaryTargetIdentity(group)), + }; +} + +function primaryTargetIdentity( + group: UnifiedSearchTargetGroup, +): string | undefined { + return ( + group.identity.requested ?? + group.identity.fresh ?? + group.identity.served ?? + group.alternatives?.target + ); +} + +function groupTerminalReason( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTerminalReason | undefined { + for (const source of group.sources) { + for (const entry of source.entries) { + if (entry.terminalReason) return entry.terminalReason; } - families.add(classifyTargetFamily(entry)); } - return TARGET_FAMILY_ORDER.filter((family) => families.has(family)); + return undefined; +} + +function groupTerminalFamily( + group: UnifiedSearchTargetGroup, +): UnifiedSearchTargetFamily | undefined { + return groupTerminalReason(group)?.family; +} + +function groupHasTerminalReason(group: UnifiedSearchTargetGroup): boolean { + return groupTerminalReason(group) !== undefined; +} + +function groupHasIndexing(group: UnifiedSearchTargetGroup): boolean { + return ( + group.freshnessKind === "indexing" || + group.freshnessKind === "pending" || + group.sources.some((source) => + source.entries.some((entry) => entry.state === "waiting"), + ) + ); +} + +function hasBareTerminalReasonForGroup( + group: UnifiedSearchTargetGroup, +): boolean { + return ( + groupHasTerminalReason(group) && + group.sources.some((source) => + source.entries.some( + (entry) => entry.state === "searched" || entry.state === "waiting", + ), + ) + ); +} + +function familyForTarget( + target: string | undefined, +): UnifiedSearchTargetFamily { + if (!target) return "unknown"; + if (isSiteTarget(target, { targetLabel: target, source: "docs" })) { + return "site"; + } + const packageSeparator = target.indexOf(":"); + if ( + packageSeparator > 0 && + isKnownRegistry(target.slice(0, packageSeparator)) + ) { + return "package"; + } + if (target.startsWith("github:")) return "repository"; + try { + parseRepositoryTargetSpec(target); + return "repository"; + } catch { + return "unknown"; + } +} + +function composePackageTarget( + identity: string, + version: string | undefined, +): string | undefined { + if (!version) return undefined; + try { + const parsed = parsePackageSpec(identity); + return `${parsed.registry}:${parsed.name}@${version}`; + } catch { + return undefined; + } +} + +function composeRepositoryTarget( + identity: string, + ref: string, +): string | undefined { + try { + const parsed = parseRepositoryTargetSpec(identity); + if (!parsed.repoUrl) return undefined; + return formatRepositoryTarget(parsed.repoUrl, ref); + } catch { + return undefined; + } } function classifyTargetFamily( @@ -1263,50 +1540,6 @@ function classifyTargetFamily( return "unknown"; } -function firstAlternative( - alternatives: UnifiedSearchAlternativeFacts[], -): UnifiedSearchAction | undefined { - for (const alternative of alternatives) { - const version = alternative.versions[0]; - if (version) { - return { - kind: "indexed_alternative", - target: alternative.target, - category: "version", - value: version.version ?? version.ref, - }; - } - const ref = alternative.refs[0]; - if (ref) { - return { - kind: "indexed_alternative", - target: alternative.target, - category: "ref", - value: ref.ref, - }; - } - } - return undefined; -} - -function hasIndexingTrustSignal( - sourceStatus: UnifiedSearchSourceStatusPayload[] | undefined, -): boolean { - return Boolean( - sourceStatus?.some( - (entry) => - entry.indexingStatus === "INDEXING" || - entry.codeIndexState === "INDEXING" || - entry.codeIndexState === "PROVISIONAL" || - entry.targetResolution?.freshness === "indexing" || - entry.targetResolution?.freshness === "provisional" || - entry.contributors?.some( - (contributor) => contributor.freshness === "PROVISIONAL", - ), - ), - ); -} - function hasRestrictiveFilters( query: UnifiedSearchQueryEcho | undefined, ): boolean { diff --git a/packages/mcp/src/shared/unified-search-status-text.test.ts b/packages/mcp/src/shared/unified-search-status-text.test.ts index c0fd0158..c2ddf89d 100644 --- a/packages/mcp/src/shared/unified-search-status-text.test.ts +++ b/packages/mcp/src/shared/unified-search-status-text.test.ts @@ -56,12 +56,11 @@ describe("renderUnifiedSearchStatusText", () => { const text = renderUnifiedSearchStatusText(payload); expect(firstLine(text)).toBe( - "Indexing continues - 1 interim result returned", + "1 interim result | 1 docs page | indexing | 0/1 ready", ); expect(text).toContain( "express/routing [docs page] npm:express - source URL unavailable - Routing", ); - expect(text).toContain("Search search-ref-status | 0/1 target ready"); expect(text).toContain( 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', ); @@ -76,7 +75,7 @@ describe("renderUnifiedSearchStatusText", () => { }), ); expect(firstLine(statusText)).toBe( - "Indexing continues - 1 partial result returned", + "1 partial result | 1 docs page | indexing | 0/1 ready", ); }); @@ -85,7 +84,7 @@ describe("renderUnifiedSearchStatusText", () => { result: result({ partialResults: true, results: [hit()] }), }); const text = renderUnifiedSearchStatusText(payload); - expect(firstLine(text)).toContain("1 partial result returned"); + expect(firstLine(text)).toContain("1 partial result"); expect(text).not.toContain("1 interim result"); }); @@ -101,11 +100,12 @@ describe("renderUnifiedSearchStatusText", () => { }, }), ); - expect(firstLine(text)).toBe("Preparing - no result snapshot yet"); + expect(firstLine(text)).toBe( + "No result snapshot yet | preparing | 0/1 ready", + ); expect(text).not.toContain("Indexing:"); expect(text).not.toContain("No hits"); expect(text).toContain("- npm:express"); - expect(text).toContain("Search search-ref-status | 0/1 target ready"); expect(text).toContain( 'Next: search_status search_ref="search-ref-status" wait_timeout_ms=20000', ); @@ -126,12 +126,12 @@ describe("renderUnifiedSearchStatusText", () => { }), }; const text = renderUnifiedSearchStatusText(payload); - expect(firstLine(text)).toContain("No results returned"); - expect(text).toContain("- npm:express@5.2.1\n Searched: code"); + expect(firstLine(text)).toBe("No results"); + expect(text).toContain("- npm:express@5.2.1\n searched: code"); expect(text).toContain( 'Next: shorten or broaden query; use source="symbol"; use code_grep.', ); - expect(text).toContain("Search search-ref-empty | completed"); + expect(text).not.toContain("Search search-ref-empty | completed"); }); it("terminal target recovery renders typed guidance for stored results", () => { @@ -173,13 +173,12 @@ describe("renderUnifiedSearchStatusText", () => { const text = renderUnifiedSearchStatusText(payload); expect(text).toContain( - "Next: verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository.", + "Fix: verify registry coordinate/version; use its public GitHub repo for", ); - expect(text).toContain( - "Next: verify the public GitHub repository target and ref.", - ); - expect(text).toContain("Next: verify the standalone site target."); - expect(text).toContain("Next: verify or replace the unavailable target."); + expect(text).toContain("repo-wide search."); + expect(text).toContain("Fix: verify public GitHub repository/ref."); + expect(text).toContain("Fix: verify site host/path."); + expect(text).toContain("Fix: verify or replace target."); expect(text).not.toContain("rerun search later"); expect(text).not.toContain("searchRef="); }); @@ -195,7 +194,7 @@ describe("renderUnifiedSearchStatusText", () => { }; const text = renderUnifiedSearchStatusText(payload); expect(firstLine(text)).toContain("1 result"); - expect(text).toContain("Search search-ref-evidence | completed"); + expect(text).not.toContain("Search search-ref-evidence | completed"); expect(text).toContain( 'Next: search_status search_ref="search-ref-evidence" wait_timeout_ms=20000', ); @@ -217,7 +216,9 @@ describe("renderUnifiedSearchStatusText", () => { }, }), ); - expect(firstLine(text)).toStartWith(status); + expect(firstLine(text)).toBe( + `No result snapshot | ${status.toLowerCase()} | 0/1 ready`, + ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); @@ -236,7 +237,7 @@ describe("renderUnifiedSearchStatusText", () => { }), ); expect(firstLine(text)).toBe( - "FUTURE_SESSION_STATE - no result snapshot returned", + "No result snapshot | status unknown | 0/1 ready", ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("Do not poll"); diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 1ee66e49..fea31d38 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -258,9 +258,7 @@ describe("renderUnifiedSearchSuccess", () => { expect(text.split("\n")[0]).toBe( "10 results | 5 repo docs, 5 docs pages | next_offset=10", ); - expect(text).toContain( - "Sources: expressjs.com; expressjs/express@dbac741a", - ); + expect(text).toContain("Sources: npm:express@5.2.1 - docs"); expect(text).toContain( "[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01", ); @@ -565,8 +563,8 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toContain("No results returned"); - expect(text).toContain("\n- npm:express@5.2.1\n Searched: code"); + expect(firstLine(text)).toBe("No results"); + expect(text).toContain("\n- npm:express@5.2.1\n searched: code"); expect(text).toContain( 'Next: shorten or broaden query; remove restrictive filters; use source="symbol"; use code_grep.', ); @@ -588,7 +586,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("Searched: symbols"); + expect(text).toContain("searched: symbols"); expect(text).not.toContain("repository docs"); }); @@ -602,7 +600,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("Searched: code, symbols"); + expect(text).toContain("searched: code, symbols"); }); it("source and warning provenance renders lane and target attribution", () => { @@ -628,13 +626,9 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("Ignored filter (docs on npm:express): fileIntent"); - expect(text).toContain( - "Ignored query feature (symbol on npm:express): name", - ); - expect(text).toContain( - "Incompatible filter (future-lane on opaque-target): lang", - ); + expect(text).toContain("ignored filter (docs): fileIntent"); + expect(text).toContain("ignored query\n feature (symbol): name"); + expect(text).toContain("incompatible filter (future-lane): lang"); }); it("terminal target recovery renders one positive line per target family", () => { @@ -670,16 +664,15 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "Next: verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository.", + "Fix: verify registry coordinate/version; use its public GitHub repo for", ); - expect(text).toContain( - "Next: verify the public GitHub repository target and ref.", - ); - expect(text).toContain("Next: verify the standalone site target."); - expect(text).toContain("Next: verify or replace the unavailable target."); + expect(text).toContain("repo-wide search."); + expect(text).toContain("Fix: verify public GitHub repository/ref."); + expect(text).toContain("Fix: verify site host/path."); + expect(text).toContain("Fix: verify or replace target."); expect(text).not.toContain("rerun search later"); expect(text).not.toContain("searchRef"); - expect(text.match(/Next:/g)).toHaveLength(4); + expect(text.match(/Fix:/g)).toHaveLength(4); const cliText = renderUnifiedSearchSuccess( completed([], { @@ -693,8 +686,9 @@ describe("renderUnifiedSearchSuccess", () => { { actionSyntax: "cli" }, ); expect(cliText).toContain( - "Next: verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository.", + "Fix: verify registry coordinate/version; use its public GitHub repo for", ); + expect(cliText).toContain("repo-wide search."); expect(cliText).not.toContain("search_status"); }); @@ -717,11 +711,9 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "Next: verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository.", - ); - expect(text).not.toContain( - "Next: verify the public GitHub repository target and ref.", + "Fix: verify registry coordinate/version; use its public GitHub repo for", ); + expect(text).not.toContain("Fix: verify public GitHub repository/ref."); }); it.each(["docs", "auto"] as const)( @@ -733,7 +725,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("- npm:express@4.18.2\n Searched: docs"); + expect(text).toContain("- npm:express@4.18.2\n searched: docs"); expect(text).not.toContain("repository docs"); }, ); @@ -742,19 +734,18 @@ describe("renderUnifiedSearchSuccess", () => { const text = renderUnifiedSearchSuccess(n8nActiveEmpty()); expect(text).toBe( - "Indexing - no results yet\n\n" + + "No results yet | indexing | 0/1 ready\n\n" + "- npm:n8n -> 2.36.7\n" + - " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages;\n" + - " capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master\n\n" + - "Search fabUr1S3MEVeSgD93pMoSQ | 0/1 target ready\n" + + " indexing: code, repository docs; available: n8n.io docs (1,480 pages; capped);\n" + + " indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master\n\n" + 'Next: search_status search_ref="fabUr1S3MEVeSgD93pMoSQ" wait_timeout_ms=20000', ); expect(text).not.toContain("Do not repeat"); expect(text).not.toContain("indexingRef"); expect(text).not.toContain("freshnessReason"); expect(text).not.toContain("Opaque evidence notice"); - expect(text.match(/Indexing/g)).toHaveLength(2); - expect(text.match(/Available now:/g)).toHaveLength(1); + expect(text.match(/indexing/g)).toHaveLength(2); + expect(text.match(/available:/g)).toHaveLength(1); expect(text.match(/Next:/g)).toHaveLength(1); }); @@ -841,8 +832,12 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("Indexing - no result snapshot yet"); - expect(text).toContain("Search ref_abc-123 | 0/2 targets ready"); + expect(firstLine(text)).toBe( + "No result snapshot yet | indexing | 0/2 ready", + ); + expect(text).toContain( + 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', + ); }); it("does not invent source details for a true progress-only response", () => { @@ -865,12 +860,13 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("Indexing - no result snapshot yet"); - expect(text).toContain("Search ref_abc-123 | 0/1 target ready"); + expect(firstLine(text)).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(text).not.toContain("Waiting:"); expect(text).not.toContain("Searched:"); expect(text).not.toContain("n8n.io"); - expect(text).toContain("Status: indexing | Available now: versions 2.26.9"); + expect(text).toContain("indexed: versions 2.26.9"); expect(text).toContain("versions 2.26.9"); expect(text).toContain( 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', @@ -895,11 +891,11 @@ describe("renderUnifiedSearchSuccess", () => { }); it.each([ - ["CURRENT", "Status: ready"], - ["INDEXED", "Status: ready"], - ["PENDING", "Status: pending"], - ["INDEXING", "Status: indexing"], - ["PROVISIONAL", "Status: provisional"], + ["CURRENT", "ready"], + ["INDEXED", "ready"], + ["PENDING", "pending"], + ["INDEXING", "indexing"], + ["PROVISIONAL", "provisional"], ] as const)( "renders explicit target freshness %s accurately", (freshness, detail) => { @@ -946,9 +942,11 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain("- npm:express@5.1.0"); - expect(text).toContain("- npm:express -> 5.2.1"); + expect(text).toContain("- npm:express"); expect(text.match(/^- npm:express/gm)).toHaveLength(2); - expect(text).toContain("Search ref_abc-123 | 0/2 targets ready"); + expect(text).toContain( + 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', + ); }); it("renders an initial progress-only parser warning once below the outcome", () => { @@ -958,7 +956,9 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("Indexing - no result snapshot yet"); + expect(firstLine(text)).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(text).toContain("Warnings:\n - unknown qualifier"); expect(text.match(/unknown qualifier/g)).toHaveLength(1); expect(text.indexOf("Warnings:")).toBeGreaterThan(0); @@ -993,7 +993,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "Searched: example.com/reference docs | Available now: example.com/guide docs", + "searched: example.com/reference docs; available: example.com/guide docs", ); expect(text).not.toContain("not searched"); expect(text).not.toContain("for npm:example@1.0.0"); @@ -1013,14 +1013,13 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "Suggested sites: site:docs.example.com,\n site:api.example.com | More suggested sites omitted", + "available: site:docs.example.com,\n site:api.example.com", ); expect(text).toContain( 'Next: search_status search_ref="ref_abc-123" wait_timeout_ms=20000', ); expect(text).not.toContain("Next: retry one suggested site target"); - expect(text.match(/Suggested sites:/g)).toHaveLength(1); - expect(text.match(/More suggested sites omitted/g)).toHaveLength(1); + expect(text.match(/available:/g)).toHaveLength(1); }); it("does not suffix deduplicated site suggestions with a target", () => { @@ -1040,8 +1039,8 @@ describe("renderUnifiedSearchSuccess", () => { incomplete({ partialResults: false, sourceStatus }), ); - expect(text).toContain("Suggested sites: site:docs.example.com"); - expect(text).not.toContain("Suggested sites for site:example.com:"); + expect(text).toContain("available: site:docs.example.com"); + expect(text).not.toContain("available: site:example.com"); }); it("renders site retry guidance for completed and terminal site recovery", () => { @@ -1056,10 +1055,7 @@ describe("renderUnifiedSearchSuccess", () => { const completedText = renderUnifiedSearchSuccess( completed([], { sourceStatus }), ); - expect(completedText).toContain("Suggested sites: site:docs.example.com"); - expect(completedText).toContain( - "Next: retry one suggested site target explicitly.", - ); + expect(completedText).toContain("Try: site:docs.example.com"); expect(completedText).not.toContain("search_status"); const terminalText = renderUnifiedSearchSuccess( @@ -1074,10 +1070,7 @@ describe("renderUnifiedSearchSuccess", () => { }, }), ); - expect(terminalText).toContain("Suggested sites: site:docs.example.com"); - expect(terminalText).toContain( - "Next: retry one suggested site target explicitly.", - ); + expect(terminalText).toContain("Try: site:docs.example.com"); expect(terminalText).not.toContain("Next: search_status"); }); @@ -1137,12 +1130,12 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("No results returned"); + expect(firstLine(text)).toBe("No results"); expect(text).toContain( - "- npm:one@1.0.0\n Indexing: code | Searched: repository docs, docs.one.example docs", + "- npm:one@1.0.0\n searched: repository docs, docs.one.example docs; indexing: code", ); expect(text).toContain( - "- npm:two@2.0.0\n Indexing: code | Searched: repository docs, docs.two.example docs", + "- npm:two@2.0.0\n searched: repository docs, docs.two.example docs; indexing: code", ); }); @@ -1170,9 +1163,9 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("- npm:one@1.0.0\n Indexing: code"); + expect(text).toContain("- npm:one@1.0.0\n indexing: code"); expect(text).toContain( - "- site:docs.one.example\n Searched: site:docs.one.example docs", + "- site:docs.one.example\n searched: site:docs.one.example docs", ); expect(text).not.toContain("for site:"); }); @@ -1205,17 +1198,34 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("- npm:one@1.0.0\n Unavailable: code"); + expect(text).toContain("- npm:one@1.0.0\n unavailable: code"); expect(text).not.toContain( - "Unavailable: code (npm:one@1.0.0) for npm:one@1.0.0", + "unavailable: code (npm:one@1.0.0) for npm:one@1.0.0", ); - expect(text).toContain("- npm:two@2.0.0\n Searched: code"); + expect(text).toContain("- npm:two@2.0.0\n searched: code"); expect(text).toContain( - "- site:docs.one.example\n Available now: site:docs.one.example docs", + "- site:docs.one.example\n available: site:docs.one.example docs", ); expect(text).not.toContain("for site:"); }); + it("keeps terminal lane reasons bare beside searched evidence and rewrites once", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ source: "code", codeIndexState: "CURRENT" }), + source({ source: "symbol", codeIndexState: "NOT_FOUND" }), + ], + }), + ); + + expect(text).toContain("searched: code; not found: symbols"); + expect(text).not.toContain("Fix:"); + expect(text).toContain( + 'Next: shorten or broaden query; use source="symbol"; use code_grep.', + ); + }); + it("omits a singular outcome target when hits span multiple targets", () => { const text = renderUnifiedSearchSuccess( completed([ @@ -1229,9 +1239,9 @@ describe("renderUnifiedSearchSuccess", () => { }); it.each([ - ["PENDING", "Preparing"], - ["INDEXING", "Indexing"], - ["SEARCHING", "Searching"], + ["PENDING", "preparing"], + ["INDEXING", "indexing"], + ["SEARCHING", "searching"], ] as const)( "keeps %s lifecycle distinct without a snapshot", (status, label) => { @@ -1245,9 +1255,9 @@ describe("renderUnifiedSearchSuccess", () => { }, }), ); - expect(firstLine(text)).toStartWith(label); - expect(firstLine(text)).toContain("no result snapshot yet"); - expect(firstLine(text)).not.toContain("No results yet"); + expect(firstLine(text)).toBe( + `No result snapshot yet | ${label} | 0/1 ready`, + ); }, ); @@ -1287,7 +1297,9 @@ describe("renderUnifiedSearchSuccess", () => { }, }), ); - expect(firstLine(text)).toStartWith(status); + expect(firstLine(text)).toBe( + `No result snapshot | ${status.toLowerCase()} | 0/1 ready`, + ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); @@ -1306,12 +1318,12 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(firstLine(text)).toBe( - "FUTURE_SESSION_STATE - no result snapshot returned", + "No result snapshot | status unknown | 1/2 ready", ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("Do not poll"); expect(text).not.toContain("Next: search_status"); - expect(text).not.toContain("indexing"); + expect(text).not.toContain("FUTURE_SESSION_STATE"); }); it("renders stale and provisional evidence as trust limits without raw diagnostics", () => { @@ -1335,8 +1347,8 @@ describe("renderUnifiedSearchSuccess", () => { ], }), ); - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes | Searched: code"); + expect(text).toContain("- npm:express latest"); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes; searched: code"); expect(text).not.toContain("Evidence:"); expect(text).not.toContain("idx-hidden"); expect(text).not.toContain("exact_provisional"); @@ -1370,9 +1382,9 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(firstLine(text)).toBe("1 result | 1 repo code hit"); - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text.match(/Using:/g)).toHaveLength(1); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text).toContain("- npm:express latest"); + expect(text.match(/using:/g)).toHaveLength(1); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes"); }); it("treats indexing hit freshness as stale served evidence", () => { @@ -1389,9 +1401,9 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(firstLine(text)).toBe("1 result | 1 repo code hit"); - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text.match(/Using:/g)).toHaveLength(1); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text).toContain("- npm:express latest"); + expect(text.match(/using:/g)).toHaveLength(1); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes"); }); it("shows a served older version from progress-only stale identity", () => { @@ -1414,9 +1426,9 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text.match(/Using:/g)).toHaveLength(1); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text).toContain("- npm:express latest"); + expect(text.match(/using:/g)).toHaveLength(1); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes"); expect(text).not.toContain("(using"); }); @@ -1440,7 +1452,7 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(text).toContain( - "- npm:express latest\n Using: 5.1.0 (older snapshot)", + "- npm:express latest\n using: 5.1.0 (older snapshot)", ); expect(text).not.toContain("- npm:express latest -> 5.1.0"); }); @@ -1466,7 +1478,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("No results returned"); + expect(firstLine(text)).toBe("No results"); }); it.each([ @@ -1499,7 +1511,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); - expect(firstLine(text)).toBe("No results returned"); + expect(firstLine(text)).toBe("No results"); expect(firstLine(text)).not.toContain(targetLabel); expect(firstLine(text)).not.toContain(freshTarget); }, @@ -1512,8 +1524,7 @@ describe("renderUnifiedSearchSuccess", () => { evidenceNotice: "Opaque backend prose must not be copied.", }), ); - expect(firstLine(text)).toBe("No results returned"); - expect(text).toContain("Search search-ref-evidence | completed"); + expect(firstLine(text)).toBe("No results"); expect(text).toContain( 'Next: search_status search_ref="search-ref-evidence" wait_timeout_ms=20000', ); @@ -1530,14 +1541,14 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(firstLine(text)).toContain("1 result"); - expect(text).toContain("Search search-ref-results | completed"); expect(text).toContain( 'Next: search_status search_ref="search-ref-results" wait_timeout_ms=20000', ); const lines = text.split("\n"); const actionLine = lines.findIndex((line) => line.startsWith("Next: ")); expect(actionLine).toBeGreaterThan(0); - expect(lines[actionLine - 1]).toBe("Search search-ref-results | completed"); + expect(lines[actionLine - 1]).toBe(""); + expect(text).toContain("Search/replace block parser"); expect(text).not.toContain("Evidence may change."); expect(text).not.toContain("Do not repeat"); }); @@ -1558,11 +1569,11 @@ describe("renderUnifiedSearchSuccess", () => { ], }), ); - expect(firstLine(text)).toContain("No results returned"); + expect(firstLine(text)).toBe("No results"); expect(text).toContain("Warnings:"); expect(text).toContain("kind was ignored by the selected source"); - expect(text).toContain("Incompatible query feature"); - expect(text).toContain("Ignored filter"); + expect(text).toContain("incompatible query feature\n (code): kind"); + expect(text).toContain("ignored filter (code): category"); expect(text).not.toContain("duplicated promoted warning"); expect(text.match(/Warnings:/g)).toHaveLength(1); }); @@ -1607,7 +1618,7 @@ describe("renderUnifiedSearchSuccess", () => { ], }), ); - expect(text).toContain("Next: search indexed version 5.1.0"); + expect(text).toContain("Try: npm:express@5.1.0"); expect(text).not.toContain("shorten or broaden query"); expect(text).not.toContain("code_grep"); }); @@ -1664,7 +1675,7 @@ describe("renderUnifiedSearchSuccess", () => { "[2] aider/edit-formats [docs page] aider-AI/aider - aider.chat/docs/more/edit-formats.html -\n Edit Formats", ); expect(text).toContain( - "Available now: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD,\n main, next +1", + "indexed: versions 5.2.1, 5.2.0, 5.1.0 +1, refs HEAD, main,", ); expect(text).toContain("next_offset=10"); expect(cliText).toContain("next_offset=10"); @@ -1682,7 +1693,7 @@ describe("renderUnifiedSearchSuccess", () => { }); expect(presentation.hasMore).toBe(true); - expect(text).toContain("No results returned | next_offset=10"); + expect(text).toContain("No results | next_offset=10"); }); it("keeps pagination in active and terminal result headlines", () => { @@ -1754,21 +1765,16 @@ describe("renderUnifiedSearchSuccess", () => { const lines = text.split("\n"); const summaryLines = lines.filter((line) => - /^( {2})?(Indexing|Searched|Available now|Suggested sites)/.test(line), + /^( {2})?(indexing|searched|available|indexed)/.test(line), ); expect(summaryLines.length).toBeGreaterThanOrEqual(3); expect(summaryLines.every((line) => line.length <= 80)).toBe(true); expect(text).toContain(targetOne); expect(text).toContain(targetTwo); - expect(text).toContain(longRef); - expect(text).toContain("More suggested sites omitted"); - expect(text).toContain( - "Next: search indexed version 1.0.0 for npm:one-long-package@1.0.0.", - ); + expect(text).not.toContain(longRef); + expect(text).toContain("Try: npm:one-long-package@1.0.0"); - const overlongLines = lines.filter((line) => line.length > 80); - expect(overlongLines).toHaveLength(1); - expect(overlongLines[0]).toContain(longRef); + expect(lines.every((line) => line.length <= 80)).toBe(true); }); it("wraps target details at the caller-supplied full output width", () => { @@ -1782,7 +1788,9 @@ describe("renderUnifiedSearchSuccess", () => { ); expect(detailLines(narrow).every((line) => line.length <= 60)).toBe(true); expect(detailLines(wide).every((line) => line.length <= 140)).toBe(true); - expect(wide).toContain("n8n.io docs (1,480 pages; capped), versions"); + expect(wide).toContain( + "n8n.io docs (1,480 pages; capped); indexed: versions", + ); }); it("shows capped searched coverage without repeating the trust limit", () => { @@ -1811,7 +1819,7 @@ describe("renderUnifiedSearchSuccess", () => { }), ); expect(text).toContain( - "Searched: docs.example.com docs (120 pages; partial)", + "searched: docs.example.com docs (120 pages; partial)", ); expect(text.match(/120 pages/g)).toHaveLength(1); }); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 6ed813ba..27d9f983 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -27,6 +27,8 @@ import { type UnifiedSearchSourceGroup, type UnifiedSearchSourceKind, type UnifiedSearchTargetGroup, + type UnifiedSearchTargetRecovery, + type UnifiedSearchTerminalReason, type UnifiedSearchTrustLimit, type UnifiedSearchWarning, } from "./unified-search-presentation.js"; @@ -93,9 +95,7 @@ export function renderUnifiedSearchPresentationText( } const hasPostResultBlock = - presentation.searchRef !== undefined || - presentation.progress !== undefined || - presentation.action.kind !== "none"; + presentation.progress !== undefined || presentation.action.kind !== "none"; if ( result.results.length > 0 && hasPostResultBlock && @@ -104,7 +104,6 @@ export function renderUnifiedSearchPresentationText( lines.push(""); } - appendPresentationSession(lines, presentation, settings); appendPresentationAction(lines, presentation, settings); return lines.join("\n"); } @@ -134,8 +133,6 @@ function formatPresentationOutcome( nextOffset: number | undefined, options: NormalizedTextOptions, ): string { - const target = presentationTarget(presentation, results); - const targetSuffix = target ? ` ${target}` : ""; const count = presentation.availability.resultCount; const countLabel = `${count} result${count === 1 ? "" : "s"}`; const finish = (value: string): string => @@ -147,16 +144,30 @@ function formatPresentationOutcome( if (presentation.lifecycle.kind === "active") { const label = activeLifecycleLabel(presentation.lifecycle); + const readiness = presentation.progress + ? `${presentation.progress.targetsReady}/${presentation.progress.targetsTotal} ready` + : undefined; if (presentation.availability.kind === "no_snapshot") { - return finish(`${label}${targetSuffix} - no result snapshot yet`); + return finish( + ["No result snapshot yet", label, readiness].filter(Boolean).join(SEP), + ); } if (presentation.availability.kind === "empty") { - return finish(`${label}${targetSuffix} - no results yet`); + return finish( + ["No results yet", label, readiness].filter(Boolean).join(SEP), + ); } const resultKind = presentation.availability.kind === "partial" ? "partial" : "interim"; return finish( - `${label} continues - ${countLabel.replace("result", `${resultKind} result`)} returned`, + [ + countLabel.replace("result", `${resultKind} result`), + formatResultBreakdown(results), + label, + readiness, + ] + .filter(Boolean) + .join(SEP), ); } @@ -164,16 +175,27 @@ function formatPresentationOutcome( return finish( count > 0 ? formatCompletedResultsHeadline(results, countLabel) - : `No results returned${target ? ` from ${target}` : ""}`, + : "No results", ); } - const status = presentation.lifecycle.status ?? "UNKNOWN"; - if (count > 0) return finish(`${status} - ${countLabel} returned`); + const status = formatLifecycleSummary(presentation.lifecycle); + const readiness = presentation.progress + ? `${presentation.progress.targetsReady}/${presentation.progress.targetsTotal} ready` + : undefined; + if (count > 0) { + return finish( + [countLabel, formatResultBreakdown(results), status, readiness] + .filter(Boolean) + .join(SEP), + ); + } if (presentation.availability.kind === "no_snapshot") { - return finish(`${status} - no result snapshot returned`); + return finish( + ["No result snapshot", status, readiness].filter(Boolean).join(SEP), + ); } - return finish(`${status} - no results returned`); + return finish(["No results", status, readiness].filter(Boolean).join(SEP)); } function formatCompletedResultsHeadline( @@ -261,41 +283,12 @@ function activeLifecycleLabel( ): string { switch (lifecycle.status) { case "PENDING": - return "Preparing"; + return "preparing"; case "INDEXING": - return "Indexing"; + return "indexing"; case "SEARCHING": - return "Searching"; - } -} - -function presentationTarget( - presentation: UnifiedSearchPresentation, - results: UnifiedSearchHitPayload[], -): string | undefined { - if (presentation.targetGroups.length > 0) return undefined; - if (presentation.targets.length > 1) return undefined; - if (results.length > 0) { - const sourceTargets = presentation.sources.flatMap((group) => - group.entries.map((entry) => entry.searchTarget), - ); - const identities = [ - ...results.map((result) => result.target), - ...sourceTargets, - ]; - if (new Set(identities).size > 1) return undefined; - return results[0]?.target; + return "searching"; } - if (presentation.targets.length === 1) { - const target = presentation.targets[0]; - return target?.served ?? target?.fresh ?? target?.requested; - } - const sourceTargets = presentation.sources.flatMap((group) => - group.entries.map((entry) => entry.searchTarget), - ); - if (new Set(sourceTargets).size > 1) return undefined; - const source = presentation.sources[0]?.entries[0]; - return source?.searchTarget ?? source?.target; } function appendPresentationContext( @@ -305,7 +298,7 @@ function appendPresentationContext( ): void { if (shouldRenderCompactSources(presentation)) { lines.push(""); - appendCompactSources(lines, presentation.sources, options); + appendCompactSources(lines, presentation.targetGroups, options); } else if (presentation.targetGroups.length > 0) { lines.push(""); presentation.targetGroups.forEach((group, index) => { @@ -322,11 +315,7 @@ function shouldRenderCompactSources( if ( presentation.lifecycle.kind !== "completed" || presentation.availability.resultCount === 0 || - presentation.sources.length === 0 || - presentation.targetGroups.length === 0 || - presentation.alternatives.length > 0 || - presentation.trustLimits.length > 0 || - presentation.siteSuggestions.length > 0 + presentation.targetGroups.length === 0 ) { return false; } @@ -335,6 +324,7 @@ function shouldRenderCompactSources( group.alternatives === undefined && group.siteSuggestions.length === 0 && group.trustLimits.length === 0 && + group.recovery === undefined && (group.freshnessKind === undefined || group.freshnessKind === "current") && group.sources.every((source) => @@ -345,85 +335,59 @@ function shouldRenderCompactSources( function appendCompactSources( lines: string[], - sources: UnifiedSearchSourceGroup[], + groups: UnifiedSearchTargetGroup[], options: NormalizedTextOptions, ): void { - const values = sources - .flatMap((source) => - source.entries.map((entry) => ({ - rank: compactSourceRank(source.kind), - value: formatCompactSource(source.kind, entry), - })), - ) - .filter( - (entry): entry is { rank: number; value: string } => - entry.value.length > 0, - ) - .sort((left, right) => left.rank - right.rank) - .map((entry) => entry.value); + const values = groups.flatMap((group) => { + const identity = + group.identity.served ?? group.identity.fresh ?? group.identity.requested; + if (!identity) return []; + const kinds = [ + ...new Set( + group.sources.flatMap((source) => + source.entries + .filter((entry) => entry.state === "searched") + .map(() => compactSourceLane(source.kind)), + ), + ), + ].sort((left, right) => compactLaneRank(left) - compactLaneRank(right)); + return kinds.length > 0 ? [`${identity} - ${kinds.join(", ")}`] : []; + }); const unique = [...new Set(values)]; if (unique.length === 0) return; lines.push(...wrapText(`Sources: ${unique.join("; ")}`, options.width)); } -function compactSourceRank(kind: UnifiedSearchSourceKind): number { +function compactSourceLane( + kind: UnifiedSearchSourceKind, +): "code" | "symbols" | "docs" { switch (kind) { - case "site_docs": - return 0; - case "repository_docs": - return 1; - case "docs": - return 2; case "code": - return 3; + return "code"; case "symbols": - return 4; + return "symbols"; + default: + return "docs"; } } -function formatCompactSource( - kind: UnifiedSearchSourceKind, - entry: UnifiedSearchSourceEntry, -): string { - if (kind === "site_docs") { - return ( - formatDocumentationSiteIdentity(entry.siteUrl) ?? - entry.siteKey ?? - compactTarget(entry.target) - ); - } - if (entry.repositoryUrl) { - return formatRepositoryIdentity(entry.repositoryUrl, entry.commitSha); - } - if (entry.siteUrl) { - return formatDocumentationSiteIdentity(entry.siteUrl) ?? entry.siteUrl; - } - return compactTarget(entry.target); +function compactLaneRank(kind: "code" | "symbols" | "docs"): number { + return kind === "code" ? 0 : kind === "symbols" ? 1 : 2; } -function formatRepositoryIdentity(url: string, commitSha?: string): string { - let identity = url; - try { - const parsed = new URL(url); - const path = parsed.pathname - .split("/") - .filter(Boolean) - .join("/") - .replace(/\.git$/, ""); - identity = - parsed.host === "github.com" && path ? path : `${parsed.host}/${path}`; - } catch { - identity = url.replace(/^https?:\/\//, "").replace(/\.git$/, ""); +function sourceKindRank(kind: UnifiedSearchSourceKind): number { + switch (kind) { + case "code": + return 0; + case "symbols": + return 1; + case "repository_docs": + return 2; + case "site_docs": + return 3; + case "docs": + return 4; } - if (!commitSha) return identity; - return `${identity}@${commitSha.slice(0, 8)}`; -} - -function compactTarget(value: string): string { - return value - .replace(/^site:/, "") - .replace(/^github:/, "") - .replace(/@[^/@#]+$/, ""); } function appendPresentationTargetGroup( @@ -435,6 +399,65 @@ function appendPresentationTargetGroup( lines.push(options.useColors ? highlight(identity, true) : identity); const details: string[] = []; + const using = formatUsingSegment(group); + if (using) details.push(using); + + const searched = formatSourceStateSegment(group, "searched"); + if (searched) details.push(`searched: ${searched}`); + const indexing = formatSourceStateSegment(group, "waiting"); + if (indexing) details.push(`indexing: ${indexing}`); + + const unavailable = formatUnavailableSegment(group); + if (unavailable) details.push(unavailable); + + const available = formatAvailableSegment(group); + if (available) details.push(`available: ${available}`); + + if (group.recovery === undefined) { + const indexed = formatTargetAlternatives(group.alternatives); + if (indexed) details.push(`indexed: ${indexed}`); + } + + const constraints = formatTargetConstraints(group); + if (constraints) details.push(constraints); + + if (details.length === 0 && group.freshnessKind !== undefined) { + details.push(formatTargetStatus(group.freshnessKind)); + } + + if (details.length > 0) { + lines.push(...wrapHangingText(details.join("; "), " ", options.width)); + } + if (group.recovery) { + const recovery = formatTargetRecovery(group.recovery, group); + lines.push( + ...wrapHangingText(recovery, " ", options.width).map((line) => + options.useColors ? `${colors.yellow}${line}${colors.reset}` : line, + ), + ); + } +} + +function formatTargetStatus( + freshness: NonNullable, +): string { + switch (freshness) { + case "current": + return "ready"; + case "pending": + return "pending"; + case "provisional": + return "provisional"; + case "stale": + return "older snapshot"; + case "indexing": + return "indexing"; + } +} + +function formatUsingSegment( + group: UnifiedSearchTargetGroup, +): string | undefined { const stale = group.trustLimits .filter( (limit): limit is Extract => @@ -453,83 +476,156 @@ function appendPresentationTargetGroup( (group.freshnessKind === "stale" || group.freshnessKind === "indexing") && group.identity.served !== (group.identity.fresh ?? group.identity.requested); - if (stale || identityIsStale) { - const served = - stale?.servedTarget ?? stale?.target ?? group.identity.served; - const fresh = stale?.freshTarget ?? group.identity.fresh; - details.push( - `Using: ${compactRelatedTarget(group.identity.requested, served ?? "older snapshot")}${fresh ? ` while ${compactRelatedTarget(group.identity.requested, fresh)} indexes` : " (older snapshot)"}`, - ); - } else if (group.trustLimits.some((limit) => limit.kind === "provisional")) { - details.push("Indexing: provisional snapshot is searchable"); + if (!stale && !identityIsStale) { + return group.trustLimits.some((limit) => limit.kind === "provisional") + ? "using: provisional snapshot" + : undefined; } + const served = stale?.servedTarget ?? stale?.target ?? group.identity.served; + const fresh = stale?.freshTarget ?? group.identity.fresh; + return `using: ${compactRelatedTarget(group.identity.requested, served ?? "older snapshot")}${fresh ? ` while ${compactRelatedTarget(group.identity.requested, fresh)} indexes` : " (older snapshot)"}`; +} - const states: Array<{ - state: UnifiedSearchSourceEntry["state"]; - label: string; - }> = [ - { state: "waiting", label: "Indexing" }, - { state: "searched", label: "Searched" }, - { state: "available_not_searched", label: "Available now" }, - { state: "unavailable", label: "Unavailable" }, - ]; - for (const { state, label } of states) { - const entries = group.sources.flatMap((source) => +function formatSourceStateSegment( + group: UnifiedSearchTargetGroup, + state: UnifiedSearchSourceEntry["state"], +): string | undefined { + const values = group.sources + .flatMap((source) => source.entries .filter((entry) => entry.state === state) - .map((entry) => ({ source, entry })), - ); - if (entries.length === 0) continue; - const values = entries.map(({ source, entry }) => - formatGroupedSource(source, entry, group.trustLimits), - ); - details.push(`${label}: ${[...new Set(values)].join(", ")}`); - } + .map((entry) => ({ + rank: sourceKindRank(source.kind), + value: formatGroupedSource(source, entry, group.trustLimits), + })), + ) + .sort((left, right) => left.rank - right.rank) + .map((entry) => entry.value); + const unique = [...new Set(values)]; + return unique.length > 0 ? unique.join(", ") : undefined; +} - if (details.length === 0 && group.freshnessKind !== undefined) { - details.push(`Status: ${formatTargetStatus(group.freshnessKind)}`); - } +function formatUnavailableSegment( + group: UnifiedSearchTargetGroup, +): string | undefined { + const entries = group.sources.flatMap((source) => + source.entries + .filter((entry) => entry.state === "unavailable") + .map((entry) => ({ source, entry })), + ); + if (entries.length === 0) return undefined; + const mixed = group.sources.some((source) => + source.entries.some( + (entry) => entry.state === "searched" || entry.state === "waiting", + ), + ); + const values = entries + .map(({ source, entry }) => { + const lane = formatGroupedSource(source, entry, group.trustLimits); + const reason = entry.terminalReason; + const value = reason + ? `${formatTerminalReason(reason, mixed)}: ${lane}` + : `unavailable: ${lane}`; + return { rank: sourceKindRank(source.kind), value }; + }) + .sort((left, right) => left.rank - right.rank) + .map((entry) => entry.value); + return [...new Set(values)].join("; "); +} - const ready = formatTargetAlternatives(group.alternatives); - if (ready) { - const readyIndex = details.findIndex((detail) => - detail.startsWith("Available now:"), +function formatAvailableSegment( + group: UnifiedSearchTargetGroup, +): string | undefined { + const values = group.sources + .flatMap((source) => + source.entries + .filter((entry) => entry.state === "available_not_searched") + .map((entry) => ({ + rank: sourceKindRank(source.kind), + value: formatGroupedSource(source, entry, group.trustLimits), + })), + ) + .sort((left, right) => left.rank - right.rank) + .map((entry) => entry.value); + if (group.recovery === undefined) { + values.push( + ...group.siteSuggestions.flatMap((suggestion) => suggestion.suggestions), ); - if (readyIndex >= 0) - details[readyIndex] = `${details[readyIndex]}, ${ready}`; - else details.push(`Available now: ${ready}`); + if (group.siteSuggestions.some((suggestion) => suggestion.truncated)) { + values.push("+more"); + } } + const unique = [...new Set(values)]; + return unique.length > 0 ? unique.join(", ") : undefined; +} - const suggestions = [ - ...new Set(group.siteSuggestions.flatMap((item) => item.suggestions)), - ]; - if (suggestions.length > 0) { - details.push(`Suggested sites: ${suggestions.join(", ")}`); - } - if (group.siteSuggestions.some((item) => item.truncated)) { - details.push("More suggested sites omitted"); +function formatTerminalReason( + reason: UnifiedSearchTerminalReason, + mixed: boolean, +): string { + const family = reason.family === "unknown" ? "target" : reason.family; + if (reason.kind === "not_found") { + return mixed ? "not found" : `${family} not found`; } + if (mixed) return "unresolved"; + if (reason.specificity === "version") return "version unavailable"; + if (reason.specificity === "ref") return "repository ref unresolved"; + return `${family} unresolved`; +} - if (details.length > 0) { - lines.push(...wrapHangingText(details.join(" | "), " ", options.width)); - } +function formatTargetConstraints( + group: UnifiedSearchTargetGroup, +): string | undefined { + const values = group.trustLimits.flatMap((limit) => { + if (limit.kind !== "constraint") return []; + const label = limit.constraint.replaceAll("_", " "); + const source = limit.source ? ` (${limit.source})` : ""; + return [`${label}${source}: ${limit.values.join(", ")}`]; + }); + const unique = [...new Set(values)]; + return unique.length > 0 ? unique.join("; ") : undefined; } -function formatTargetStatus( - freshness: NonNullable, +function formatTargetRecovery( + recovery: UnifiedSearchTargetRecovery, + group: UnifiedSearchTargetGroup, ): string { - switch (freshness) { - case "current": - return "ready"; - case "pending": - return "pending"; - case "provisional": - return "provisional"; - case "stale": - return "older snapshot"; - case "indexing": - return "indexing"; + if (recovery.kind === "fix") { + switch (recovery.family) { + case "package": + return "Fix: verify registry coordinate/version; use its public GitHub repo for repo-wide search."; + case "repository": + return "Fix: verify public GitHub repository/ref."; + case "site": + return "Fix: verify site host/path."; + case "unknown": + return "Fix: verify or replace target."; + } } + if (recovery.additionalTargets.length === 0 && !recovery.truncated) { + return `Try: ${recovery.target}`; + } + const additional = recovery.additionalTargets.map((target) => + compactRelatedTarget(group.identity.requested, target), + ); + const remaining = + recovery.category === "version" + ? (group.alternatives?.versionsRemaining ?? 0) + : recovery.category === "ref" + ? (group.alternatives?.refsRemaining ?? 0) + + (group.alternatives?.suggestedRefsRemaining ?? 0) + : 0; + const label = + recovery.category === "site" ? "also suggested" : "also indexed"; + const suffix = [ + ...additional, + ...(remaining > 0 + ? [`+${remaining}`] + : recovery.truncated + ? ["+more"] + : []), + ]; + return `Try: ${recovery.target} (${label}: ${suffix.join(", ")})`; } function formatGroupedSource( @@ -556,9 +652,25 @@ function formatGroupedSource( : "docs"; const qualifiers: string[] = []; if (coverageDetails) qualifiers.push(coverageDetails); + if (entry.state === "searched" && hasProvisionalTrust(entry, trustLimits)) { + qualifiers.push("provisional"); + } return `${identity}${qualifiers.length > 0 ? ` (${qualifiers.join("; ")})` : ""}`; } +function hasProvisionalTrust( + entry: UnifiedSearchSourceEntry, + trustLimits: UnifiedSearchTrustLimit[], +): boolean { + return trustLimits.some( + (limit) => + limit.kind === "provisional" && + (!limit.target || + limit.target === entry.target || + limit.target === entry.searchTarget), + ); +} + function formatDocumentationSourceIdentity( group: UnifiedSearchSourceGroup, entry: UnifiedSearchSourceEntry, @@ -582,18 +694,17 @@ function formatCoverageLimit( } function formatTargetGroupIdentity(group: UnifiedSearchTargetGroup): string { - const { requested, fresh, served } = group.identity; - const primary = requested ?? fresh ?? served ?? "target"; - const staleLike = - group.trustLimits.some((limit) => limit.kind === "stale") || - group.freshnessKind === "stale" || - group.freshnessKind === "indexing"; - const resolved = fresh ?? (staleLike ? undefined : served); - const resolution = - resolved && resolved !== primary - ? ` -> ${compactRelatedTarget(primary, resolved)}` - : ""; - return `${primary}${resolution}`; + const primary = + group.identity.requested ?? + group.identity.fresh ?? + group.identity.served ?? + "target"; + if (formatUsingSegment(group)) return primary; + const resolved = group.identity.fresh ?? group.identity.served; + if (resolved && resolved !== primary) { + return `${primary} -> ${compactRelatedTarget(primary, resolved)}`; + } + return primary; } function compactRelatedTarget(base: string | undefined, value: string): string { @@ -672,30 +783,11 @@ function appendPresentationWarnings( } } -function appendPresentationSession( - lines: string[], - presentation: UnifiedSearchPresentation, - options: NormalizedTextOptions, -): void { - const parts: string[] = []; - if (presentation.searchRef) parts.push(`Search ${presentation.searchRef}`); - if (presentation.progress) { - const { targetsReady, targetsTotal } = presentation.progress; - parts.push( - `${targetsReady}/${targetsTotal} target${targetsTotal === 1 ? "" : "s"} ready`, - ); - } else if (presentation.searchRef) { - parts.push(formatLifecycleSummary(presentation.lifecycle)); - } - if (parts.length === 0) return; - if (lines[lines.length - 1] !== "") lines.push(""); - lines.push(dim(parts.join(" | "), options.useColors)); -} - function formatLifecycleSummary(lifecycle: UnifiedSearchLifecycle): string { if (lifecycle.kind === "completed") return "completed"; if (lifecycle.kind === "active") return lifecycle.status.toLowerCase(); - return lifecycle.status?.toLowerCase() ?? "status unknown"; + if (lifecycle.kind === "terminal") return lifecycle.status.toLowerCase(); + return "status unknown"; } function formatRemaining(count: number): string { @@ -709,11 +801,7 @@ function appendPresentationAction( ): void { const action = presentation.action; if (action.kind === "none") return; - if ( - presentation.searchRef === undefined && - presentation.progress === undefined && - lines[lines.length - 1] !== "" - ) { + if (lines[lines.length - 1] !== "") { lines.push(""); } if (action.kind === "poll" || action.kind === "status") { @@ -728,22 +816,6 @@ function appendPresentationAction( lines.push("Next: rerun search later."); return; } - if (action.kind === "indexed_alternative") { - lines.push( - `Next: search indexed ${action.category} ${action.value}${action.target ? ` for ${action.target}` : ""}.`, - ); - return; - } - if (action.kind === "site_retry") { - lines.push("Next: retry one suggested site target explicitly."); - return; - } - if (action.kind === "verify_target") { - for (const family of action.families) { - lines.push(`Next: ${formatTargetVerification(family)}.`); - } - return; - } if (action.kind === "query_rewrite") { lines.push( `Next: ${action.rewrites @@ -753,24 +825,6 @@ function appendPresentationAction( } } -function formatTargetVerification( - family: Extract< - UnifiedSearchAction, - { kind: "verify_target" } - >["families"][number], -): string { - switch (family) { - case "package": - return "verify the registry package coordinate and version; for repository-wide evidence, use its public GitHub repository"; - case "repository": - return "verify the public GitHub repository target and ref"; - case "site": - return "verify the standalone site target"; - case "unknown": - return "verify or replace the unavailable target"; - } -} - function formatRewrite( rewrite: NonNullable< Extract diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index ed328eb9..173e085b 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -162,7 +162,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - 'Indexing - no result snapshot returned yet\nNext: search_status search_ref="smoke-ref" wait_timeout_ms=20000\nsearch_ref=leaked', + 'No result snapshot yet | indexing | 0/1 ready\nNext: search_status search_ref="smoke-ref" wait_timeout_ms=20000\nsearch_ref=leaked', ); } return smokeResponse(name, args); @@ -220,8 +220,8 @@ describe("runMcpSmoke", () => { if (name === "search" && args.format !== "json") { return textResult( smokeSearchText().replace( - "- npm:express@5.2.1\n Indexing: code | Available now: versions 5.2.1", - " Using: 5.1.0 while 5.2.1 indexes", + "- npm:express@5.2.1\n indexing: code; available: versions 5.2.1", + " using: 5.1.0 while 5.2.1 indexes", ), ); } @@ -246,7 +246,7 @@ describe("runMcpSmoke", () => { if (name === "search" && args.format !== "json") { return textResult( smokeSearchText().replace( - " Indexing: code | Available now: versions 5.2.1", + " indexing: code; available: versions 5.2.1", `${section} 0/1 targets`, ), ); @@ -296,7 +296,7 @@ describe("runMcpSmoke", () => { if (name === "search" && args.format !== "json") { return textResult( smokeSearchText().replace( - "Indexing - no result snapshot yet", + "No result snapshot yet | indexing | 0/1 ready", "Warnings:", ), ); @@ -469,7 +469,7 @@ describe("runMcpSmoke", () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { return textResult( - `${smokeSearchText()}\nIndexing - no result snapshot yet`, + `${smokeSearchText()}\nNo result snapshot yet | indexing | 0/1 ready`, ); } return smokeResponse(name, args); @@ -534,10 +534,9 @@ function smokeResponse( return textResult("package.json: express"); case "search": return textResult( - "Indexing - no result snapshot yet\n\n" + + "No result snapshot yet | indexing | 0/1 ready\n\n" + "- npm:express@5.2.1\n" + - " Indexing: code | Available now: versions 5.2.1\n\n" + - "Search smoke-ref | 0/1 target ready\n" + + " indexing: code; available: versions 5.2.1\n\n" + 'Next: search_status search_ref="smoke-ref" wait_timeout_ms=20000', ); case "search_status": diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index df1f50bf..4b5a1d29 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -187,7 +187,7 @@ function assertSearchDefaultText(text: string, context: string): void { const firstLine = lines[0]?.trim() ?? ""; assert(firstLine.length > 0, `${context}: missing outcome first line`); assert( - /^(?:Preparing|Indexing|Searching)\b|^No results returned\b|^\d+ results?\b|^[A-Z_]+ - /.test( + /^(?:No result snapshot yet|No results yet|No result snapshot|No results)\b|^\d+ (?:partial |interim )?results?\b/.test( firstLine, ), `${context}: missing outcome headline`, @@ -201,8 +201,12 @@ function assertSearchDefaultText(text: string, context: string): void { !formatterLines.some((line) => /^status\s*:/i.test(line.trim())), `${context}: duplicated lifecycle status line`, ); + assert( + !formatterLines.some((line) => /^Search\s+\S+\s+\|/.test(line)), + `${context}: separate Search session summary`, + ); const lifecycleOutcomeLines = lines.filter((line) => - /^(?:Preparing|Indexing|Searching)\b/.test(line), + /\|\s+(?:preparing|indexing|searching)(?:\s*\||$)/.test(line), ); assert( lifecycleOutcomeLines.length <= 1, @@ -243,7 +247,7 @@ function assertSearchDefaultText(text: string, context: string): void { ); const hasReadinessText = formatterLines.some((line) => - /^ {2}(?! {2}).*(?:Indexing|Searched|Available now|Unavailable|Using|Status):/.test( + /^ {2}(?! {2}).*(?:indexing|searched|available|unavailable|using|ready|pending|provisional|older snapshot):/.test( line, ), ); @@ -281,18 +285,7 @@ function assertSearchDefaultText(text: string, context: string): void { ); const match = refLine.match(/search_ref=(?:"([^"]+)"|(\S+))/); const searchRef = match?.[1] ?? match?.[2]; - const summaryLines = lines.filter((line) => - /^Search\s+\S+\s+\|/.test(line), - ); - assert( - summaryLines.length === 1, - `${context}: expected one Search session summary`, - ); - assert( - searchRef !== undefined && - summaryLines[0]?.startsWith(`Search ${searchRef} |`), - `${context}: session summary does not match search_ref action`, - ); + assert(searchRef !== undefined, `${context}: missing search_ref value`); } assert( !formatterText.includes("githits search-status ") && diff --git a/packages/mcp/src/tools/search-status.test.ts b/packages/mcp/src/tools/search-status.test.ts index 9dd93f07..4ed154b0 100644 --- a/packages/mcp/src/tools/search-status.test.ts +++ b/packages/mcp/src/tools/search-status.test.ts @@ -125,7 +125,7 @@ describe("searchStatusTool", () => { const text = await tool.handler({ search_ref: incomplete.searchRef }, {}); expect(text.content[0]?.text).toContain( - "Indexing: provisional snapshot is searchable", + "using: provisional snapshot; searched: code (provisional)", ); expect(text.content[0]?.text).toContain( 'Next: search_status search_ref="search-ref-provisional" wait_timeout_ms=20000', @@ -271,9 +271,9 @@ describe("searchStatusTool", () => { const text = await tool.handler({ search_ref: "search-ref-docs" }, {}); expect(text.content[0]?.text).toContain( - "Indexing: expressjs.com/en/guide docs", + "indexing: expressjs.com/en/guide docs", ); - expect(text.content[0]?.text).toContain("Searched: repository docs"); + expect(text.content[0]?.text).toContain("searched: repository docs"); }); it("keeps completed empty JSON structured", async () => { @@ -352,7 +352,7 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-timeout" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("TIMEOUT - no result snapshot returned"); + expect(text).toContain("No result snapshot | timeout | 0/1 ready"); expect(text).not.toContain("search_status |"); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); @@ -369,7 +369,7 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-failed" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("FAILED - no result snapshot returned"); + expect(text).toContain("No result snapshot | failed | 0/1 ready"); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); }); @@ -412,7 +412,7 @@ describe("searchStatusTool", () => { const textResult = await tool.handler({ search_ref: "ref-deferred" }, {}); const text = textResult.content[0]?.text ?? ""; - expect(text).toContain("DEFERRED - 1 result returned"); + expect(text).toContain("1 result | 1 repo code hit | deferred | 1/2 ready"); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); @@ -432,7 +432,7 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-deferred-empty" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("DEFERRED - no result snapshot returned"); + expect(text).toContain("No result snapshot | deferred | 0/1 ready"); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("No hits"); expect(text).not.toContain("Indexing in progress"); @@ -471,7 +471,9 @@ describe("searchStatusTool", () => { const textResult = await tool.handler({ search_ref: "ref-future" }, {}); const text = textResult.content[0]?.text ?? ""; - expect(text).toContain("FUTURE_SESSION_STATE - 1 result returned"); + expect(text).toContain( + "1 result | 1 repo code hit | status unknown | 0/1 ready", + ); expect(text).toContain("Next: rerun search later."); expect(text).not.toContain("search_ref="); expect(text).not.toContain("No hits"); @@ -480,8 +482,8 @@ describe("searchStatusTool", () => { }); it.each([ - ["FAILED", "FAILED - no results returned"], - ["TIMEOUT", "TIMEOUT - no results returned"], + ["FAILED", "No results | failed | 0/1 ready"], + ["TIMEOUT", "No results | timeout | 0/1 ready"], ] as const)( "does not promise future hits for a terminal %s partial result", async (status, expectedMessage) => { @@ -551,11 +553,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: incomplete.searchRef }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("Indexing - no results yet"); + expect(text).toContain("No results yet | indexing | 0/1 ready"); expect(text).toContain("- site:example.com"); - expect(text).toContain("Searched: site:example.com docs"); - expect(text).toContain("Suggested sites: site:docs.example.com"); - expect(text).toContain("More suggested sites omitted"); + expect(text).toContain("searched: site:example.com docs"); + expect(text).toContain("available: site:docs.example.com"); + expect(text).toContain("+more"); expect(text).toContain( 'Next: search_status search_ref="ref-site-recovery" wait_timeout_ms=20000', ); @@ -607,8 +609,8 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "ref-stale" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("- npm:express latest -> 5.2.1"); - expect(text).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(text).toContain("- npm:express latest"); + expect(text).toContain("using: 5.1.0 while 5.2.1 indexes"); expect(text.match(/5\.1\.0 while 5\.2\.1 indexes/g)).toHaveLength(1); }); @@ -655,8 +657,8 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "search-ref-123" }, {}); const text = result.content[0]?.text ?? ""; expect(text).toContain("- npm:express@4.18.2"); - expect(text).toContain("Using: 4.18.2 (older snapshot)"); - expect(text).toContain("Available now: versions"); + expect(text).toContain("using: 4.18.2 (older snapshot)"); + expect(text).toContain("indexed: versions"); expect(text).toContain("4.18.2"); expect(text).not.toContain("ref_resolution_deferred"); }); @@ -697,12 +699,11 @@ describe("searchStatusTool", () => { const result = await tool.handler({ search_ref: "search-ref-123" }, {}); const text = result.content[0]?.text ?? ""; - expect(text).toContain("No results returned"); + expect(text).toContain("No results"); expect(text).toContain("- site:example.com"); - expect(text).toContain("Searched: site:example.com docs"); - expect(text).toContain("Suggested sites: site:example.com/docs"); - expect(text).toContain("More suggested sites omitted"); - expect(text).toContain("Next: retry one suggested site target explicitly."); + expect(text).toContain("searched: site:example.com docs"); + expect(text).toContain("Try: site:example.com/docs"); + expect(text).toContain("+more"); expect(text).not.toContain("Next: shorten or broaden site query."); }); @@ -753,9 +754,9 @@ describe("searchStatusTool", () => { {}, ); const text = result.content[0]?.text ?? ""; - expect(text).toContain("No results returned"); + expect(text).toContain("No results"); expect(text).toContain("- github:githits-com/no-such-repo"); - expect(text).toContain("Unavailable: code"); + expect(text).toContain("repository unresolved: code"); expect(text).not.toContain("Searched: code"); expect(text).not.toContain("Repository ref cannot be resolved"); expect(text).not.toContain("state=indexing"); @@ -774,8 +775,8 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(result.isError).toBeUndefined(); - expect(text).toContain("Searching - no result snapshot yet"); - expect(text).toContain("Search ref-text | 0/1 target ready"); + expect(text).toContain("No result snapshot yet | searching | 0/1 ready"); + expect(text).not.toContain("Search ref-text |"); expect(text).toContain( 'Next: search_status search_ref="ref-text" wait_timeout_ms=20000', ); @@ -807,7 +808,7 @@ describe("searchStatusTool", () => { const text = result.content[0]?.text ?? ""; expect(text).toContain("- npm:express latest"); - expect(text).toContain("Available now: versions 4.18.2, refs main"); + expect(text).toContain("indexed: versions 4.18.2, refs main"); expect(text).toContain( 'Next: search_status search_ref="ref-alternatives" wait_timeout_ms=20000', ); diff --git a/packages/mcp/src/tools/search.test.ts b/packages/mcp/src/tools/search.test.ts index 3c92dd88..740eabc0 100644 --- a/packages/mcp/src/tools/search.test.ts +++ b/packages/mcp/src/tools/search.test.ts @@ -169,9 +169,9 @@ describe("searchTool", () => { {}, ); expect(text.content[0]?.text).toContain( - "Indexing: expressjs.com/en/guide docs", + "indexing: expressjs.com/en/guide docs", ); - expect(text.content[0]?.text).toContain("Searched: repository docs"); + expect(text.content[0]?.text).toContain("searched: repository docs"); }); it("passes compiled request through to code navigation service", async () => { diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index a3c8f433..b0c3d9c0 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -377,7 +377,7 @@ export function assertSearchTerminalText(text: string, context: string): void { `${context}: non-outcome text precedes search outcome`, ); assert( - /^(?:Preparing|Indexing|Searching)\b|^No results returned\b|^\d+ results?\b|^[A-Z_]+ - /.test( + /^(?:No result snapshot yet|No results yet|No result snapshot|No results)\b|^\d+ (?:partial |interim )?results?\b/.test( firstLine, ), `${context}: missing outcome headline`, @@ -386,8 +386,12 @@ export function assertSearchTerminalText(text: string, context: string): void { !formatterLines.some((line) => /^status\s*:/i.test(line.trim())), `${context}: duplicated lifecycle status line`, ); + assert( + !formatterLines.some((line) => /^Search\s+\S+\s+\|/.test(line)), + `${context}: separate Search session summary`, + ); const lifecycleOutcomeLines = formatterLines.filter((line) => - /^(?:Preparing|Indexing|Searching)\b/.test(line), + /\|\s+(?:preparing|indexing|searching)(?:\s*\||$)/.test(line), ); assert( lifecycleOutcomeLines.length <= 1, @@ -433,7 +437,7 @@ export function assertSearchTerminalText(text: string, context: string): void { ); const hasReadinessText = formatterLines.some((line) => - /^ {2}(?! {2}).*(?:Indexing|Searched|Available now|Unavailable|Using|Status):/.test( + /^ {2}(?! {2}).*(?:indexing|searched|available|unavailable|using|ready|pending|provisional|older snapshot):/.test( line, ), ); @@ -456,28 +460,11 @@ export function assertSearchTerminalText(text: string, context: string): void { statusActions.length <= 1, `${context}: expected at most one search-status action`, ); - const summaryLines = formatterLines.filter((line) => - /^Search\s+\S+\s+\|/.test(line), - ); - assert( - summaryLines.length <= 1, - `${context}: expected at most one Search session summary`, - ); - if (statusActions.length > 0) { - assert( - summaryLines.length === 1, - `${context}: expected one Search session summary`, - ); - } if (statusActions.length === 1) { const searchRef = statusActions[0]?.match( /^Next: githits search-status (\S+) /, )?.[1]; - assert( - searchRef !== undefined && - summaryLines[0]?.startsWith(`Search ${searchRef} |`), - `${context}: session summary does not match search-status action`, - ); + assert(searchRef !== undefined, `${context}: missing search-status ref`); } assert( !formatterText.includes("search_ref="), diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index be235ce1..c0c68104 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -25,17 +25,17 @@ import { import { toStdioLaunch } from "./smoke-launch-target.ts"; describe("CLI search smoke contract", () => { - const valid = `Indexing - no results yet + const valid = `No results yet | indexing | 0/1 ready -- npm:n8n -> 2.36.7 - Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master +- npm:n8n + indexing: code, repository docs; available: n8n.io docs (1,480 pages; capped); + indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master -Search smoke-ref | 0/1 target ready Next: githits search-status smoke-ref --wait 20`; - const completedWithTargetReadiness = `No results returned from npm:express + const completedWithTargetReadiness = `No results - npm:express@4.18.2 - Searched: repository docs + searched: repository docs Next: shorten or broaden query; use githits code grep.`; const completed = `1 result | 1 repo code hit | next_offset=10 @@ -46,23 +46,21 @@ Next: shorten or broaden query; use githits code grep.`; [1] page-1 [docs page] npm:express - docs.example.com/getting-started - Getting started | API - section`; it("accepts outcome-first text with CLI-native actions", () => { - expect(valid.split("\n")[0]).toBe("Indexing - no results yet"); - expect(valid).toContain("- npm:n8n -> 2.36.7"); - expect(valid).toContain( - " Indexing: code, repository docs | Available now:", - ); - expect(valid).toContain("Search smoke-ref | 0/1 target ready"); + expect(valid.split("\n")[0]).toBe("No results yet | indexing | 0/1 ready"); + expect(valid).toContain("- npm:n8n"); + expect(valid).toContain(" indexing: code, repository docs; available:"); + expect(valid).not.toContain("Search smoke-ref"); expect(valid).toContain("Next: githits search-status smoke-ref --wait 20"); expect(() => assertSearchTerminalText(valid, "search")).not.toThrow(); expect(() => assertSearchTerminalText( - "No results returned from npm:express\nNext: shorten or broaden query; use githits code grep.", + "No results\nNext: shorten or broaden query; use githits code grep.", "search", ), ).not.toThrow(); expect(() => assertSearchTerminalText( - "FAILED - no results returned\nNext: rerun search later.", + "No result snapshot | failed | 0/1 ready\nNext: rerun search later.", "search", ), ).not.toThrow(); @@ -162,25 +160,25 @@ Next: shorten or broaden query; use githits code grep.`; ).not.toThrow(); }); - it("requires Using details to remain grouped under a target", () => { + it("requires using details to remain grouped under a target", () => { expect(() => assertSearchTerminalText( valid.replace( - "- npm:n8n -> 2.36.7\n Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", - " Using: 2.26.9 while 2.36.7 indexes", + "- npm:n8n\n indexing: code, repository docs; available: n8n.io docs (1,480 pages;", + " using: 2.26.9 while 2.36.7 indexes", ), "search", ), ).toThrow("readiness details must be grouped under a target"); }); - it("rejects duplicate search session summaries", () => { + it("rejects a separate search session summary", () => { expect(() => assertSearchTerminalText( `${valid}\nSearch another-ref | 0/1 target ready`, "search", ), - ).toThrow("expected at most one Search session summary"); + ).toThrow("separate Search session summary"); }); it.each([ @@ -202,7 +200,10 @@ Next: shorten or broaden query; use githits code grep.`; it("rejects duplicate lifecycle, status, and Next lines", () => { expect(() => - assertSearchTerminalText(`${valid}\nIndexing - no results yet`, "search"), + assertSearchTerminalText( + `${valid}\nNo results yet | indexing | 0/1 ready`, + "search", + ), ).toThrow("duplicate lifecycle outcome lines"); expect(() => assertSearchTerminalText(`${valid}\nstatus: indexing`, "search"), @@ -225,17 +226,14 @@ Next: shorten or broaden query; use githits code grep.`; expect(() => assertSearchTerminalText( valid.replace( - " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", - " Available now: versions 2.36.7", + " indexing: code, repository docs; available: n8n.io docs (1,480 pages;", + " available: versions 2.36.7", ), "search", ), ).not.toThrow(); expect(() => - assertSearchTerminalText( - valid.replace("- npm:n8n -> 2.36.7\n", ""), - "search", - ), + assertSearchTerminalText(valid.replace("- npm:n8n\n", ""), "search"), ).toThrow("readiness details must be grouped under a target"); }); @@ -246,7 +244,7 @@ Next: shorten or broaden query; use githits code grep.`; ["search_ref=payload", "MCP search_ref syntax leaked into CLI output"], ])("rejects target-detail diagnostic %s", (diagnostic, message) => { const readinessLine = - " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages; capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master"; + " indexing: code, repository docs; available: n8n.io docs (1,480 pages;"; const targetDetail = valid.replace(readinessLine, ` ${diagnostic}`); expect(() => assertSearchTerminalText(targetDetail, "search")).toThrow( diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 3abc65ff..4565bd72 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -442,15 +442,15 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain( - "- site:example.com\n Searched: site:example.com docs | Suggested sites: site:example.com/docs,\n site:example.com/guide", + "- site:example.com\n searched: site:example.com docs", ); + expect(output).toContain("Try: site:example.com/docs"); + expect(output).toContain("site:example.com/guide"); expect(output).not.toContain("Additional site targets were omitted."); - expect(output).toContain("Search search-ref-123 | completed"); - expect(output).toContain( - "Next: retry one suggested site target explicitly.", - ); + expect(output).not.toContain("Search search-ref-123 | completed"); + expect(output).not.toContain("Next:"); consoleSpy.mockRestore(); }); @@ -521,11 +521,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); + expect(output).toMatch(/searched:\s+repository\s+docs/); expect(output).toMatch( - /Available now: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, + /available: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, ); expect(output).not.toContain("Documentation sources:"); expect(output).not.toContain("Documentation corpora"); @@ -534,7 +534,6 @@ describe("searchAction", () => { expect(output).not.toContain("Try a shorter or broader query"); expect(output).not.toContain("Run again with a larger --wait"); expect(output).not.toContain("Evidence may change."); - expect(output).toContain("Search search-ref-docs | completed"); expect(output).toContain( "Next: githits search-status search-ref-docs --wait 20", ); @@ -562,10 +561,10 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); - expect(output).toContain("Available now: expressjs.com/en/guide docs"); + expect(output).toMatch(/searched:\s+repository\s+docs/); + expect(output).toContain("available: expressjs.com/en/guide docs"); expect(output).not.toContain("Do not repeat"); consoleSpy.mockRestore(); }); @@ -605,9 +604,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("Indexing: code"); + expect(output).toContain("indexing: code"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toContain("Searched: repository docs"); + expect(output).toMatch(/searched:\s+repository\s+docs/); expect(output).toContain("Next: rerun search later."); consoleSpy.mockRestore(); }); @@ -709,10 +708,7 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("1 result | 1 docs page"); - expect(output).toContain("- npm:express@5.1.0"); - expect(output).toContain( - "Searched: repository docs, expressjs.com/en/guide docs", - ); + expect(output).toContain("Sources: npm:express@5.1.0 - docs"); expect(output).toContain( "[1] express/routing [docs page] npm:express - expressjs.com/en/guide/routing.html -\n Routing", ); @@ -975,8 +971,9 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no result snapshot yet"); - expect(output).toContain("Search search-ref-123 | 0/1 target ready"); + expect(output.split("\n")[0]).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", ); @@ -1085,20 +1082,19 @@ describe("searchAction", () => { const initial = String(consoleSpy.mock.calls[0]?.[0]); expect(initial).toBe( [ - "Indexing - no results yet", + "No results yet | indexing | 0/1 ready", "", "- npm:n8n -> 2.36.7", - " Indexing: code, repository docs | Available now: n8n.io docs (1,480 pages;", - " capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", + " indexing: code, repository docs; available: n8n.io docs (1,480 pages; capped);", + " indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master", "", - "Search n8n-search-ref | 0/1 target ready", "Next: githits search-status n8n-search-ref --wait 20", ].join("\n"), ); - expect(initial.match(/^Indexing\b/gm)).toHaveLength(1); - expect(initial.match(/^Search /gm)).toHaveLength(1); + expect(initial.match(/^No results yet/gm)).toHaveLength(1); + expect(initial.match(/^Search /gm)).toBeNull(); expect(initial.match(/^Next:/gm)).toHaveLength(1); - expect(initial.match(/n8n-search-ref/g)).toHaveLength(2); + expect(initial.match(/n8n-search-ref/g)).toHaveLength(1); expect(initial).not.toContain("search_status search_ref="); expect(initial).not.toContain("Warning:"); expect(initial).not.toContain("Evidence may change"); @@ -1127,11 +1123,11 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no results yet"); + expect(output.split("\n")[0]).toBe("No results yet | indexing | 0/1 ready"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); + expect(output).toMatch(/searched:\s+repository\s+docs/); expect(output).toMatch( - /Available now: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, + /available: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, ); expect(output).not.toContain("Evidence may change."); expect(output).toContain("githits search-status search-ref-docs"); @@ -1157,17 +1153,19 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); + expect(output.split("\n")[0]).toBe( + "1 result | 1 repo code hit | deferred | 0/1 ready", + ); expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain( "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); - expect(output).toContain("Search ref-deferred | 0/1 target ready"); + expect(output).not.toContain("Search ref-deferred"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("re-run with the searchRef"); expect(output).not.toContain("still indexing"); - expect(output).not.toContain("No results"); + expect(output).not.toContain("No results\n"); expect(output).not.toContain("Indexing/search still in progress"); consoleSpy.mockRestore(); }); @@ -1195,13 +1193,13 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe( - "FUTURE_SESSION_STATE - 1 result returned", + "1 result | 1 repo code hit | status unknown | 0/1 ready", ); expect(output).toContain("- npm:express@4.18.2"); expect(output).toContain( "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); - expect(output).toContain("Search ref-future | 0/1 target ready"); + expect(output).not.toContain("Search ref-future"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("re-run with the searchRef"); @@ -1249,15 +1247,13 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no results yet"); + expect(output.split("\n")[0]).toBe("No results yet | indexing | 0/1 ready"); expect(output).toContain("- site:example.com"); - expect(output).toContain("Indexing: site:example.com docs"); - expect(output).toContain( - "Incompatible filter (docs on site:example.com): language", - ); - expect(output).toContain("Suggested sites: site:docs.example.com"); - expect(output).toContain("More suggested sites omitted"); - expect(output).toContain("Search search-ref-site | 0/1 target ready"); + expect(output).toContain("indexing: site:example.com docs"); + expect(output).toContain("incompatible filter (docs): language"); + expect(output).toContain("available: site:docs.example.com"); + expect(output).not.toContain("Try: site:docs.example.com"); + expect(output).toContain("+more"); expect(output).toContain( "Next: githits search-status search-ref-site --wait 20", ); @@ -1306,9 +1302,7 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "Ignored filter (docs on npm:express@4.18.2): fileIntent", - ); + expect(output).toContain("ignored filter (docs): fileIntent"); expect(output).not.toContain("Note: docs on npm:express@4.18.2"); consoleSpy.mockRestore(); }); @@ -1355,12 +1349,8 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain( - "Ignored query feature (docs on npm:express@4.18.2): kind", - ); - expect(output).toContain( - "Incompatible query feature (docs on npm:express@4.18.2): name", - ); + expect(output).toContain("ignored query feature (docs): kind"); + expect(output).toContain("incompatible query feature\n (docs): name"); expect(output).not.toContain("Note: docs on npm:express@4.18.2"); consoleSpy.mockRestore(); }); @@ -1402,8 +1392,8 @@ describe("searchAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output).toContain("- npm:express latest -> 5.2.1"); - expect(output).toContain("Using: 5.1.0 while 5.2.1 indexes"); + expect(output).toContain("- npm:express latest"); + expect(output).toContain("using: 5.1.0 while 5.2.1 indexes"); expect(output).not.toContain("Evidence:"); consoleSpy.mockRestore(); }); @@ -1521,7 +1511,7 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("1 result"); expect(output).toContain( - "- npm:express@4.18.2\n Indexing: provisional snapshot is searchable | Searched: code", + "- npm:express@4.18.2\n using: provisional snapshot; searched: code (provisional)", ); expect(output).not.toContain("Evidence may change."); expect(output).not.toContain("Evidence:"); @@ -1583,8 +1573,8 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("- github:expressjs/express#refs/heads/master"); - expect(output).toContain("Using: refs/heads/master (older snapshot)"); - expect(output).toMatch(/Available now:\s+refs master/); + expect(output).toContain("using: refs/heads/master (older snapshot)"); + expect(output).toMatch(/indexed:\s+refs\s+master/); expect(output).not.toContain("Evidence:"); expect(output).not.toContain("Indexed alternatives:"); expect(output).not.toContain("Next: githits search-status"); @@ -1996,8 +1986,10 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Searching - no result snapshot yet"); - expect(output).toContain("Search search-ref-123 | 1/1 target ready"); + expect(output.split("\n")[0]).toBe( + "No result snapshot yet | searching | 1/1 ready", + ); + expect(output).not.toContain("Search search-ref-123 |"); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", ); @@ -2031,9 +2023,11 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no result snapshot yet"); + expect(output.split("\n")[0]).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(output).toContain("- site:example.com"); - expect(output).toContain("Search search-ref-stale | 0/1 target ready"); + expect(output).not.toContain("Search search-ref-stale |"); expect(output).toContain( "Next: githits search-status search-ref-stale --wait 20", ); @@ -2086,11 +2080,11 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no results yet"); + expect(output.split("\n")[0]).toBe("No results yet | indexing | 0/1 ready"); expect(output).toContain("- site:example.com"); - expect(output).toMatch(/Searched:\s+site:example.com docs/); - expect(output).toContain("Suggested sites: site:docs.example.com"); - expect(output).toContain("Search search-ref-site | 0/1 target ready"); + expect(output).toMatch(/searched:\s+site:example.com docs/); + expect(output).not.toContain("Try: site:docs.example.com"); + expect(output).not.toContain("Search search-ref-site |"); expect(output).toContain( "Next: githits search-status search-ref-site --wait 20", ); @@ -2181,12 +2175,14 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("Indexing - no result snapshot yet"); + expect(output.split("\n")[0]).toBe( + "No result snapshot yet | indexing | 0/1 ready", + ); expect(output).toContain( "- github:expressjs/express#refs/heads/master -> master", ); - expect(output).toContain("Status: indexing | Available now: refs master"); - expect(output).toContain("Search search-ref-123 | 0/1 target ready"); + expect(output).toContain("indexed: refs master"); + expect(output).not.toContain("Search search-ref-123 |"); expect(output).toContain( "Next: githits search-status search-ref-123 --wait 20", ); @@ -2213,8 +2209,10 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("TIMEOUT - no result snapshot returned"); - expect(output).toContain("Search search-ref-timeout | 0/1 target ready"); + expect(output.split("\n")[0]).toBe( + "No result snapshot | timeout | 0/1 ready", + ); + expect(output).not.toContain("Search search-ref-timeout |"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("longer wait"); expect(output).not.toContain("Search still in progress."); @@ -2270,11 +2268,13 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("DEFERRED - 1 result returned"); + expect(output.split("\n")[0]).toBe( + "1 result | 1 repo code hit | deferred | 1/2 ready", + ); expect(output).toContain( "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); - expect(output).toContain("Search ref-deferred | 1/2 targets ready"); + expect(output).not.toContain("Search ref-deferred |"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("No results"); @@ -2308,12 +2308,12 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe( - "FUTURE_SESSION_STATE - 1 result returned", + "1 result | 1 repo code hit | status unknown | 0/1 ready", ); expect(output).toContain( "[1] npm:express@4.18.2 lib/router/index.js:42-57 [repo code] - router middleware", ); - expect(output).toContain("Search ref-future | 0/1 target ready"); + expect(output).not.toContain("Search ref-future |"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("githits search-status"); expect(output).not.toContain("No results"); @@ -2341,9 +2341,9 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe( - "DEFERRED - no result snapshot returned", + "No result snapshot | deferred | 0/1 ready", ); - expect(output).toContain("Search ref-deferred-empty | 0/1 target ready"); + expect(output).not.toContain("Search ref-deferred-empty |"); expect(output).toContain("Next: rerun search later."); expect(output).not.toContain("No results"); expect(output).not.toContain("Indexing/search still in progress"); @@ -2371,7 +2371,9 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("FAILED - no result snapshot returned"); + expect(output.split("\n")[0]).toBe( + "No result snapshot | failed | 0/1 ready", + ); expect(output).not.toContain("Search still in progress."); consoleSpy.mockRestore(); }); @@ -2442,11 +2444,11 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); + expect(output).toMatch(/searched:\s+repository\s+docs/); expect(output).toMatch( - /Available now: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, + /available: expressjs\.com\/en\/guide docs \(120 pages; partial\)/, ); expect(output).not.toContain("Evidence may change."); expect( @@ -2482,10 +2484,10 @@ describe("searchStatusAction", () => { ); const output = String(consoleSpy.mock.calls[0]?.[0]); - expect(output.split("\n")[0]).toBe("No results returned"); + expect(output.split("\n")[0]).toBe("No results"); expect(output).toContain("- npm:express@5.1.0"); - expect(output).toMatch(/Searched:\s+repository\s+docs/); - expect(output).toContain("Unavailable: expressjs.com/en/guide docs"); + expect(output).toMatch(/searched:\s+repository\s+docs/); + expect(output).toContain("unavailable: expressjs.com/en/guide docs"); consoleSpy.mockRestore(); }); @@ -2577,12 +2579,9 @@ describe("searchStatusAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output).toContain("- site:example.com"); - expect(output).toContain("Suggested sites: site:example.com/docs"); - expect(output).toContain("More suggested sites omitted"); - expect(output).toContain("Search search-ref-123 | completed"); - expect(output).toContain( - "Next: retry one suggested site target explicitly.", - ); + expect(output).toContain("Try: site:example.com/docs"); + expect(output).toContain("+more"); + expect(output).not.toContain("Search search-ref-123 | completed"); consoleSpy.mockRestore(); }); From 164fc74b8fda6d2e92a3700854a45c5cfff5661a Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 14:28:15 +0300 Subject: [PATCH 09/24] docs: document unified search target state Record the durable target-state output, release fragment, and Phase 1B completion evidence while keeping Phase 2 blocked. --- changes/search-client-recovery.fixed.md | 2 +- docs/implementation/cli-commands.md | 24 +-- docs/implementation/tools.md | 177 ++++++++++-------- ...rch-client-recovery-and-target-guidance.md | 107 ++++++----- 4 files changed, 170 insertions(+), 140 deletions(-) diff --git a/changes/search-client-recovery.fixed.md b/changes/search-client-recovery.fixed.md index f200bd2f..235bb888 100644 --- a/changes/search-client-recovery.fixed.md +++ b/changes/search-client-recovery.fixed.md @@ -3,4 +3,4 @@ "@githits/mcp": patch --- -- **Search recovery and target guidance** - Correct terminal target recovery, source provenance, and canonical package addressing. +- **Search recovery and target guidance** - Correct terminal target recovery and canonical package addressing, and unify CLI/MCP text around compact target-state output with inline recovery. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index 1b92ed6c..d9fe8fdb 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -232,29 +232,28 @@ Unified search spans indexed dependency and repository code, docs, and explicit **Intent filter.** When `--intent` is omitted, unified search sends no file-intent filter. Pass `--intent production` or another specific intent only when you want to narrow the result set. Some sources can still ignore `fileIntent`; when they do, the JSON `sourceStatus` block and terminal notes report that explicitly. -**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. The rendered `search-status` action is the concise way to continue; reissuing the same search is also valid and waits on the same underlying work. Ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the `searchRef`; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI prints the raw unrecognized status and preserves any evidence, but does not infer active or terminal semantics, claim indexing or no results, or poll the same reference; start a later new search instead. A missing or ambiguous standalone site can instead return terminal recovery guidance without a `searchRef`; callers retry an explicit `suggestedSiteTargets` label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). -**Terminal target recovery and provenance.** A completed empty result renders positive target-verification guidance when any `sourceStatus` entry carries exactly `NOT_FOUND` or `UNRESOLVABLE`; it does not require every entry to be terminal. The guidance replaces `rerun search later` and does not fabricate a `searchRef`. Package guidance includes the public GitHub repository as the route to full-repository or sibling-package evidence; repository and site targets receive their corresponding verification guidance. Existing site suggestions and indexed alternatives remain higher-information actions. `SYMBOL` readiness is presented as `symbols`, separately from `code`, and target-scoped warnings identify both the lane and target (for example, `docs on npm:express`). Structured JSON keeps the source-status and warning values unchanged. +**Complete-by-default results.** The CLI sends `allowPartialResults: false` unless `--allow-partial` is passed. Every result-bearing initial JSON payload includes the backend's exact `partialResults` Boolean; a response with no result snapshot omits that field. CLI `--json` and MCP `format: "json"` share this additive structured truth. If required indexing, crawling, or refresh work does not complete within the wait window, an active response returns a `searchRef` and progress summary. Stale-but-serveable or provisional-but-queryable evidence can accompany the reference while background refresh continues. The rendered `search-status` action is the concise way to continue; reissuing the same search is also valid and waits on the same underlying work. Ordinary cases are a known active status (`PENDING`, `INDEXING`, or `SEARCHING`) and a completed result with an evidence notice. Provisional results remain visibly marked as still indexing and retain exact served identity. With `--allow-partial`, evidence from other ready target/source pairs can also be included while remaining work continues. Terminal `DEFERRED` retains any disclosed evidence and exact progress but stops advancing the reference; use that evidence now and start a new search later for a fresher snapshot. Future backend status values remain readable rather than failing response validation. The CLI keeps unknown lifecycle output conservative and does not poll an unrecognized reference. A missing or ambiguous standalone site can instead return target-local recovery without a reference; callers can retry an explicit suggested site label when present. `--limit` defaults to 10 results. `--wait` is in seconds (0-60, default 20). +**Terminal target recovery and provenance.** The CLI renders one target-state list. Exact `NOT_FOUND` and `UNRESOLVABLE` source states become readable lane-specific reasons such as `package not found: code`, `version unavailable: code`, or `repository ref unresolved: code`. If the target also has searched or indexing evidence, the reason is bare (`not found: symbols`) and coordinate recovery is omitted. A target with no searched/indexing lane gets at most one inline `Fix:` or replayable `Try:` line; package guidance points to its public GitHub repository for full-repository or sibling-package evidence. Site suggestions and indexed alternatives stay on the affected target, and package refs remain informational rather than replayable package targets. Completed-empty and terminal site suggestions remain `Try:`-eligible even when the site lane was searched empty. `SYMBOL` readiness is presented as `symbols`, separately from `code`. Target-owned constraints stay inline with their lane; query-wide warnings and unowned source constraints remain one global block. Structured JSON keeps source-status, target-resolution, warning, and hit values unchanged. The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. Ordinary completed current results use a compact `Sources:` provenance row; target blocks with grouped readiness and usable alternatives remain whenever stale, provisional, coverage, constraint, or other trust facts must stay attached to a target. Result headlines combine count, type breakdown, and pagination, for example `10 results | 5 repo docs, 5 docs pages | next_offset=10`. Breakdown labels use `repo code hit(s)`, `repo symbol(s)`, `repo doc(s)`, and `docs page(s)`. Hits remain numbered and preserve follow-up locators in compact human form: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output uses the exact wording `Indexing - no results yet`; no-snapshot output uses `Indexing - no result snapshot yet`, with corresponding lifecycle labels for other active states. When session facts exist, the formatter may emit one optional session row composed from available `searchRef`, lifecycle, and readiness facts. With both reference and progress, it is `Search | / target(s) ready`; completed output without session facts may omit it. A reference appears once in that row when available and once in the follow-up action when the action carries it. CLI enables ANSI emphasis when supported and uses surface-native continuation actions (`githits search-status` and source-specific pivots) while hit anatomy remains shared with MCP. Removing ANSI from CLI output leaves the same hierarchy and wording apart from those actions; line breaks can differ because CLI uses the terminal width while MCP uses the 80-column default. `--json` emits the shared success/error envelope used by the MCP `search` tool, including a full `query` echo for initial searches and the exact `partialResults` Boolean on result-bearing payloads. Readiness labels distinguish `code` from `symbols`, including in mixed-source output. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row; any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. The representative CLI n8n active-empty output shape is: ```text -Indexing - no results yet +No results yet | indexing | 0/1 ready -- npm:n8n -> 2.36.7 - Indexing: code, repository docs | Available now: n8n.io docs ( pages; - capped), versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master +- npm:n8n + indexing: code, repository docs; available: n8n.io docs ( pages; capped); + indexed: versions 2.26.9, 2.26.5, 2.23.2 +2, refs HEAD, master -Search | 0/1 target ready Next: githits search-status --wait 20 ``` -**Highlighting and width.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, target details remain plain, and the optional session row is dim. Color never carries meaning or changes wording. CLI target details and hit summaries wrap to the current terminal width; MCP uses the shared 80-column fallback. +**Highlighting and width.** The shared formatter applies backend-provided title and summary spans and uses a small semantic color hierarchy on CLI: active/degraded outcomes and warnings are yellow, failed outcomes are red, primary identities and exact actions receive emphasis, and target details remain plain. Color never carries meaning or changes wording. CLI target details and hit summaries wrap to the current terminal width; MCP uses the shared 80-column fallback. -**Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups structured readiness and trust facts under each target, including searched, waiting, unavailable, stale, provisional, and capped coverage. Exact requested/fresh/served divergence appears once only when identities differ. Raw reason codes, indexing references, promoted duplicate warnings, opaque evidence prose, and the exact `evidenceNotice` remain in JSON. Constraint and warning text retains separate raw lane and target provenance, such as `Ignored filter (docs on npm:express): fileIntent`; known lanes are lowercased and unknown non-empty lanes pass through lowercased. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable next action. +**Trust signals.** The JSON `sourceStatus` block remains lossless. Shared text groups structured readiness and trust facts under each target, including searched, waiting, unavailable, stale, provisional, and capped coverage. Exact requested/fresh/served divergence appears once only when identities differ; `using:` omits the arrow when it already explains the served snapshot. Raw reason codes, indexing references, promoted duplicate warnings, opaque evidence prose, and the exact `evidenceNotice` remain in JSON. Constraint and warning text retains separate raw lane and target provenance, such as `ignored filter (docs): fileIntent`; known lanes are lowercased and unknown non-empty lanes pass through lowercased. Empty output distinguishes a searched empty snapshot from no result snapshot and selects only an applicable target-local or global action. Hits remain separate from target-state diagnostics. Contributor-bearing rows omit redundant pair-level `resultCount`, pair-level `coverage`, and healthy resolution metadata from the compact JSON projection. Other source-status signals remain unchanged: ignored / incompatible filters and query features, terminal indexing notes, promoted freshness warnings, and ordered standalone-site recovery targets. Site suggestions come from `suggestedSiteTargets`; the exact `suggestedSiteTargetsTruncated` Boolean is retained whenever suggestions are present. They are advisory labels to retry explicitly, not aliases, and the client never selects or retries one automatically. @@ -267,7 +266,7 @@ githits search-status ref_abc123 githits search-status ref_abc123 --json ``` -Follow-up for a prior unified search. Use the `searchRef` only when `githits search` emits the explicit action, including when the initial request could not complete inside the wait window or a completed result carries an evidence notice. Before completion, `search-status` can return an atomic interim result when every runnable target/source pair is serveable; if the original request used `--allow-partial`, it can instead return a serveable subset while other pairs remain unavailable. +Follow-up for a prior unified search. Use the `searchRef` only when `githits search` emits the explicit action, including when the initial request could not complete inside the wait window or a completed result carries an evidence notice. Before completion, `search-status` can return an atomic interim result when every runnable target/source pair is serveable; if the original request used `--allow-partial`, it can instead return a serveable subset while other pairs remain unavailable. Its human output uses the same single target-state list as `search`: the headline carries lifecycle/readiness, target-local state and recovery stay with each identity, hits remain separate, and one final continuation can follow. `PENDING`, `INDEXING`, and `SEARCHING` are active incomplete states and can be checked again with the same reference. `DEFERRED` is terminal even though JSON keeps `completed: false`: the session has stopped following lifecycle work, any stored `result` and progress remain usable, and a later `search` starts a fresh session when needed. `TIMEOUT` and `FAILED` are also terminal. @@ -276,7 +275,8 @@ Follow-up for a prior unified search. Use the `searchRef` only when `githits sea With `includeResults: true`, the stored `result` retains the same documentation contributors and `evidenceNotice` as the initial search result. The CLI uses the same projection and documentation-source formatter for both commands; contributors are not -duplicated onto generic progress targets. +duplicated onto generic progress targets. JSON remains the stable, lossless +follow-up contract even when text collapses healthy sources or groups recovery inline. ### `githits languages` diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 5dda6b90..4e019d66 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -143,36 +143,48 @@ Treat failures as live backend or contract findings, not deterministic unit-test **Promoted `warnings[]`.** Noteworthy `sourceStatus` entries — sources reporting `incompatibleQueryFeatures`, `ignoredQueryFeatures`, `incompatibleFilters`, `ignoredFilters`, lifecycle anomalies (`indexingStatus`, `codeIndexState`), or a free-form `note` — are also surfaced as a top-level `warnings: string[]` in the completed/incomplete payloads (and appended after parser warnings inside the `search_status` result block). The structured detail still lives in `sourceStatus`; `warnings[]` is the agent-visible signal that something about execution did not match the request. On completed empty results, healthy non-contributor source entries are also retained with zero `resultCount` and served identity; requested/fresh labels emit only when they materially differ from served. Contributor-bearing DOCS rows retain their physical contributors instead of duplicating healthy served/current resolution metadata. Healthy `INDEXED` / `CURRENT` / non-divergent `STALE` states never become warnings. `PROVISIONAL` is queryable but remains a visible non-healthy indexing signal, including on completed responses. Successful non-empty responses keep the prior compact projection. JSON keeps promoted warnings and source-status detail lossless; MCP text classifies parser/query and structured constraint facts once below the outcome and does not repeat promoted lifecycle/freshness warning prose or opaque notes. Implementation in `buildSourceStatusWarnings` and empty-result compaction (`packages/mcp/src/shared/unified-search-response.ts`). -**Standalone-site recovery.** `search` accepts exact documentation targets as `site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels are preserved in order for missing or ambiguous sites, together with the exact `suggestedSiteTargetsTruncated` Boolean. The compact source-status row becomes actionable even when it has no note or lifecycle warning, and MCP text-v1 renders replayable target labels plus an omitted-candidates notice when truncated. Suggestions are advisory rather than aliases: active known sessions keep polling their current `searchRef`, while completed or terminal recovery can expose one explicit site-retry action without selecting a label automatically. Terminal missing or ambiguous results can omit `searchRef` and instead expose recovery guidance. - -**Terminal target recovery.** A completed empty search renders positive target-verification guidance when any `sourceStatus` entry carries exactly `NOT_FOUND` or `UNRESOLVABLE`; it does not require every entry to be terminal. The guidance has one line per affected package, repository, site, or unknown display family. Package guidance includes the public GitHub repository for full-repository or sibling-package scope. This action has no `searchRef` and does not emit `rerun search later`; existing site suggestions and indexed alternatives remain higher-information actions. Other terminal session statuses retain their conservative new-search behavior. - -**Documentation sources.** DOCS `sourceStatus` rows retain bounded physical -`contributors` and coverage in JSON. Text places the user-meaningful readiness -state under its target, using `Indexing`, `Searched`, `Available now`, `Unavailable`, -`Using`, or `Status` details as applicable. `Status` appears only when the -backend supplies an explicit current, pending, indexing, provisional, or stale -target state; session activity alone does not invent target state. Site identity, -stale/provisional qualifiers, and partial or capped coverage remain attached to -that target; internal reason codes and indexing references stay in JSON. -Partial/capped coverage is published evidence, not a progress or retry signal. - -`evidenceNotice` is carried once on initial and stored result envelopes. JSON -retains that exact backend-owned notice; default text does not render it or replace -it with a generic mutable-evidence slogan. Instead, concrete stale, provisional, -pending, and coverage facts remain grouped under the affected target. A -`searchRef` is actionable only when rendered output supplies a status follow-up. -Reissuing the same search is valid and waits on the same underlying work. Terminal -status and unknown-status handling remains conservative, while -`search_status(includeResults: true)` uses the same result projection and -formatter—contributors are never copied onto generic progress targets, and -`allowPartialResults` retains its separate pair-omission meaning. - -Completed empty results distinguish the `code` and `symbols` readiness lanes. Text -constraint and warning facts retain separate raw lane and target provenance, for -example `Ignored filter (docs on npm:express): fileIntent`; known lanes are -lowercased and unknown non-empty lanes pass through lowercased. JSON keeps the -lossless `sourceStatus` and warning values unchanged. +**Standalone-site recovery.** `search` accepts exact documentation targets as +`site:`. Backend-owned `sourceStatus[].suggestedSiteTargets` labels +are preserved in order, with the exact truncation Boolean. The shared text +projection keeps them in the target group: an active suggestion-only target shows +`available:`, while a completed-empty or terminal target can show one replayable +`Try:` line with the remaining suggestions summarized there. Suggestions are +advisory rather than aliases; the client never selects or retries one automatically. + +**Unified target-state output.** MCP `search` and `search_status` text-v1 return one +outcome-first response. The headline carries result count/type breakdown, +active/terminal lifecycle, readiness, and pagination when applicable. A completed +current result set collapses to one `Sources: - ` row; any trust, +warning, alternative, suggestion, or non-current fact keeps every target in one +detailed list. Each target row can contain `using`, `searched`, `indexing`, an exact +terminal reason, `available`, `indexed`, constraints, and at most one inline +`Fix:`/`Try:` recovery line. Completed-empty and terminal site suggestions remain +`Try:`-eligible even when the site lane was searched empty. Detailed lane order is +`code`, `symbols`, `repository docs`, concrete site docs, then docs. Hits remain a +separate numbered ranked list. + +Exact `NOT_FOUND` and `UNRESOLVABLE` reasons are client-owned and lane-specific: +`package not found: code`, `version unavailable: code`, or +`repository ref unresolved: code`, for example. If the same target has searched or +indexing evidence, the reason becomes bare (`not found: symbols`) and no coordinate +recovery is suggested. Unknown unavailable states remain `unavailable`; backend +notes and reason enums are not copied into text. Query-wide warnings remain one +global `Warnings:` block after target rows and before hits; target-owned constraints +stay in their target row and unowned source constraints remain global. + +There is at most one final `Next:` line. Active continuation uses the supplied +`searchRef` exactly once in the executable `search_status` action; there is no +separate session row. MCP renders +`Next: search_status search_ref="..." wait_timeout_ms=20000`. A target-local +`Fix:`/`Try:` never suppresses an active poll or completed evidence-status action, +but suppresses generic rerun/query-rewrite guidance. Terminal and unknown sessions +do not poll their stopped reference. Reissuing the same search remains valid. + +JSON remains the lossless stable boundary: `sourceStatus`, warnings, target +resolution, evidence notices, and hit metadata are retained there even when text +collapses them. `search_status` uses the same projection for stored results; +contributors are not copied onto generic progress targets, and +`allowPartialResults` keeps its separate pair-omission meaning. ### `pkg_info` response shape @@ -317,45 +329,48 @@ The `hint` field is emitted only when the cap *actually truncated* the response **Unified search outcome-first anatomy** (CLI human search/search-status and MCP `search` / `search_status` text-v1). One shared presentation model owns target groups and trust facts; one shared text renderer owns wording, wrapping, hit -anatomy, and ordering. Callers supply only ANSI enablement and surface-native -action syntax. The order is: - -1. outcome headline; -2. one compact `Sources:` row for ordinary completed current results, or target blocks with identity plus grouped readiness and usable alternatives when trust facts require them; -3. warnings and results; -4. an optional session summary; and -5. one positive next action, when applicable. - -Active lifecycle labels remain `Preparing`, `Indexing`, and `Searching` for -`PENDING`, `INDEXING`, and `SEARCHING`. The exact active empty wording is -`Indexing - no results yet`; when no snapshot exists it is -`Indexing - no result snapshot yet`, with the corresponding lifecycle label for -other active states. Active hits are labelled `interim` when `partialResults` is -false and `partial` when it is true. Progress-only responses show only derivable -target readiness and alternatives; they never synthesize source or contributor -facts. - -When session facts exist, text may include one optional session row composed from -the facts available: `Search ` when a reference exists, aggregate -`/ target(s) ready` when progress exists, and a lifecycle summary -when a reference has no progress. The combined form is -`Search | / target(s) ready`; completed output without -session facts may omit the row. A reference appears once in that row when -available and once in the follow-up action when the action carries it; raw -diagnostic fields are not rendered. MCP renders -`Next: search_status search_ref="..." wait_timeout_ms=20000`; CLI renders -`Next: githits search-status ... --wait 20`. Text emits no negative repeat or poll -policy directive: reissuing the same search is valid and waits on the same -underlying work. Suggested site targets retain backend order and an omitted- -candidates signal, but remain advisory labels rather than automatic retries. - -`evidenceNotice` stays exact in JSON and is not rendered in default text. The -renderer keeps concrete stale, provisional, pending, and capped-coverage facts -under their target, while raw reason codes, indexing references, promoted -duplicate warnings, and opaque evidence prose remain in JSON. Query/filter and -structured-constraint facts appear once below the outcome. Surface-native pivots -name `source="symbol"` / `code_grep` in MCP and `--source symbol` / -`githits code grep` in CLI. +anatomy, and ordering. The order is: + +1. one outcome headline with count/breakdown, lifecycle, readiness, and + pagination when applicable; +2. one compact `Sources: - ` row for ordinary completed current + results, or one detailed block per target when any state must remain visible; +3. target-local state and recovery, then query-wide warnings; +4. the separate numbered ranked hit list; and +5. at most one session/query-wide `Next:` action. + +Active empty headlines are `No results yet | indexing | 0/1 ready` and +`No result snapshot yet | indexing | 0/1 ready` (with `preparing` or `searching` +for the other active states). Active results say `partial` when +`partialResults` is true and `interim` when it is false. Terminal or unknown +progress retains its lower-case lifecycle and readiness; completed output omits +those fields. Progress-only responses show only derivable target identity and +lane-free freshness; they never invent source or contributor facts. + +Detailed target rows keep one identity and deterministic segment order: +`using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, +then target-scoped constraints. Lanes are `code`, `symbols`, `repository docs`, +concrete site docs, and docs. Exact terminal states use readable client-owned +reasons (`package not found`, `version unavailable`, or `repository ref +unresolved`) plus their lane. When searched or indexing evidence exists for the +same target, the reason is bare and no coordinate recovery is suggested. A +target gets at most one inline `Fix:` or replayable `Try:` line. Stale, +provisional, and coverage facts qualify the target/source rather than creating a +second list. Query-wide warnings remain one `Warnings:` block after target rows +and before hits; target-owned constraints stay in their row. + +There is no separate session row. An active or evidence-status continuation uses +the supplied `searchRef` exactly once in the executable `Next:` action: +`Next: search_status search_ref="..." wait_timeout_ms=20000` for MCP or +`Next: githits search-status ... --wait 20` for CLI. Target-local recovery never +suppresses an active poll or completed evidence-status action, but suppresses a +generic rerun/query rewrite. Stopped terminal references are not polled. + +`evidenceNotice` stays exact in JSON and is not rendered in default text. JSON is +the lossless stable boundary for source statuses, target resolution, warnings, +evidence notices, and hit metadata; text remains a compact decision surface. +Surface-native pivots name `source="symbol"` / `code_grep` in MCP and +`--source symbol` / `githits code grep` in CLI. The representative CLI n8n example is maintained in `docs/implementation/cli-commands.md` as the output source of truth. @@ -400,20 +415,16 @@ and `docs page(s)`. When more results exist without a next offset, the final fie **Follow-up — crawled-doc section anchors.** Unified search can label a crawled documentation hit with a matching section title while returning only its page ID. Without a line anchor, `docs_read` must start at the beginning of the page. Carrying section ranges through search results requires backend/search-location support and is outside the CLI response-formatting slice. -Completed empty search uses the model's applicable action: generic query pivots are -suppressed for evidence-limited or unsearched sources, which instead direct the -caller to rerun the search later. Indexing/provisional evidence prefers waiting -or an indexed alternative, standalone site searches expose only a -shorter/broader site query, and filter removal or symbol/code-grep pivots appear -only when applicable. Surface-native pivots name -`source="symbol"` / `code_grep` in MCP and `--source symbol` / -`githits code grep` in CLI. A result with both an evidence notice and -`searchRef` emits one status continuation. Terminal `DEFERRED`, `FAILED`, and -`TIMEOUT` preserve disclosed evidence and their lifecycle state; unknown statuses -preserve the raw value without inferred semantics. Promoted lifecycle/freshness -warning prose, opaque evidence text, and the exact notice remain in JSON but are -not repeated in default text; parser/query and structured constraint facts appear -once below the outcome. +Completed-empty action selection is target-aware: exact terminal lanes with no +searched/indexing peer get local recovery, while searched-empty evidence can get +a query rewrite. Indexing or trust-only evidence gets a single rerun action when +no target-local recovery exists; standalone-site output uses its site-specific +rewrite only when applicable. Filter removal and symbol/code-grep pivots appear +only when requested constraints make them useful. Terminal `DEFERRED`, `FAILED`, +and `TIMEOUT` preserve disclosed evidence and lifecycle; unknown statuses stay +conservative and do not poll. Promoted lifecycle/freshness prose, opaque evidence +text, and the exact notice remain in JSON; parser/query and target-owned +constraint facts appear once in text. **Listing anatomy** (`code_files` text-v1): diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index add8e966..000f327c 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -2,13 +2,20 @@ ## Status -- Overall: Phase 1 COMPLETE; Phase 1B READY; Phase 2 remains BLOCKED -- Current phase: Phase 1B — unified target-state text UX ready for implementation +- Overall: Phase 1 COMPLETE; Phase 1B COMPLETE; Phase 2 remains BLOCKED +- Current phase: Phase 2 — terminal backend failure details (blocked) - Later phase: Phase 2 — terminal backend failure details (BLOCKED on private backend #2133) -- Commits: implementation runtime `56f6003`; guidance/docs `e0057e6`; runtime/preflight - fixes `c88194b`, `80f93a2`; privacy/wording review closure `b6c0581`. -- Final evidence: 3522 full tests pass; deterministic, build, package, and plugin - checks pass; all four smoke modes pass; six targeted agent evaluations pass. +- Commits: Phase 1 runtime `56f6003`; Phase 1 guidance/docs `e0057e6`; runtime/preflight + fixes `c88194b`, `80f93a2`; privacy/wording review closure `b6c0581`; Phase 1B + plan baseline `28772a3`; Phase 1B runtime `9b3523e`. +- Phase 1B worker evidence: 144 focused presentation/text/status tests pass; 218 + consumer, parity, CLI/MCP, and smoke-helper tests pass; `bun run typecheck` and + the owned-file Biome check pass. One transient worker test-suite deletion mistake + was restored before verification. The original worker lost authentication and was + replaced; the replacement resumed the existing delta without discarding it. +- Remaining validation: coordinator full-suite, build/package, live smoke, agent-eval, + and fresh external review evidence is still pending; this plan does not claim those + checks complete. - Last verified: 2026-08-29 ## Problem and expected outcome @@ -50,32 +57,21 @@ When this plan is complete: ### Client behavior - `packages/mcp/src/shared/unified-search-presentation.ts` owns the projection used by - both CLI and MCP text. Runtime commit `56f6003` maps `source === "symbol"` to - `symbols`, preserves separate lane and target provenance, and derives a typed - target-verification action for completed empty searches with any source-status entry - carrying an exact terminal state. -- `packages/mcp/src/shared/unified-search-text.ts` renders target verification as - positive family-specific guidance without a retry-later directive or fabricated - `searchRef`; other terminal session statuses retain their existing new-search - behavior. + both CLI and MCP text. Phase 1B runtime commit `9b3523e` maps `source === "symbol"` + to `symbols`, retains target-grouped lane/provenance facts, derives exact terminal + reasons, and attaches at most one typed target-local recovery value per group. +- `packages/mcp/src/shared/unified-search-text.ts` renders one outcome-first target + list. Healthy completed results collapse to one `Sources:` row; otherwise each + target carries its transient, terminal, trust, warning, alternative, and inline + `Fix:`/`Try:` facts. Only one session/query-wide `Next:` remains, with an active + `searchRef` appearing exactly once there; JSON remains unchanged. - `packages/mcp/src/shared/unified-search-response.ts` preserves source statuses and lane-aware warning information in JSON. The defect is confined to text projection; the success JSON envelope does not need a compatibility change in Phase 1. -- Focused unit, CLI, MCP, and parity tests cover terminal target verification, - distinct symbol readiness, and lane-aware warning text. Full repository validation - and internal/external review are clean after the follow-up fixes. -- The current presentation already assembles `targetGroups`, but also exposes parallel - top-level `targets`, `sources`, `siteSuggestions`, `trustLimits`, `warnings`, and - `alternatives`. The text renderer consequently emits target state, target warnings, - session facts, and target recovery in separate sections. This contradicts the - product decision to make each target the single readable unit and repeats target, - readiness, reference, and action context. -- Current active output renders lifecycle in the headline, target readiness in target - blocks, and `Search | / targets ready` in a later session row. - The same `searchRef` then appears again in `Next:`. Current terminal recovery is a - global action classified only by target family, so a multi-target caller cannot tell - which target failed or which source lane carried `NOT_FOUND` versus - `UNRESOLVABLE`. +- Focused unit, CLI, MCP, parity, and smoke-helper tests cover terminal recovery, + distinct symbol readiness, lane-aware warnings, target grouping, compact sources, + lifecycle headlines, and exactly-once continuation. Full repository validation, + live smoke, agent evaluation, and fresh external review remain coordinator work. - `packages/mcp/src/shared/package-spec.ts` validates registry and syntax only. It deliberately does not own backend package identity conventions. @@ -220,8 +216,10 @@ presentation, CLI, MCP tool, and parity layers. 1. **Phase 1 (COMPLETE):** CLI and MCP text give deterministic terminal-target recovery, preserve symbol/warning provenance, and document canonical Swift/Zig and package scope. -2. **Phase 1B (READY):** CLI and MCP text expose one token-efficient state list that - keeps every transient, terminal, trust, warning, and recovery fact with its target. +2. **Phase 1B (COMPLETE):** CLI and MCP text expose one token-efficient state list + that keeps every transient, terminal, trust, warning, and recovery fact with its + target. Runtime commit `9b3523e` and this documentation/release closure record + the implemented contract; coordinator final validation remains pending. 3. **Phase 2 (BLOCKED):** CLI and MCP expose an actionable typed cause for terminal `FAILED` sessions after private backend #2133 supplies the contract. @@ -410,7 +408,7 @@ surfaces. Internal and external reviews are clean after runtime/preflight fixes ## Phase 1B: unified target-state text UX -**Status:** READY +**Status:** COMPLETE **Expected outcome:** CLI human output and MCP `text-v1` present one compact semantic list of requested targets. Each target's searched, indexing, terminal, stale or @@ -804,11 +802,31 @@ make live checks pass. ### Documentation and release record -Update the two implementation documents from the old five-section anatomy to the new -single target-state contract and replace the representative n8n output. Keep the plan -until Phase 2 completes; record Phase 1B commits and observed verification here after -implementation. Expand the existing change fragment to mention unified target-state -text and inline recovery. Versions and `CHANGELOG.md` remain untouched. +Implemented in this closure slice. `docs/implementation/tools.md` and +`docs/implementation/cli-commands.md` now describe the single target-state list, +headline lifecycle/readiness, compact healthy `Sources:` output, inline terminal +`Fix:`/`Try:` recovery, query-only global warnings, one global `Next:`, and the +separate ranked hit list. The representative n8n output matches the runtime +formatter. Both documents state that JSON remains the lossless stable boundary. + +The existing `changes/search-client-recovery.fixed.md` fragment remains the one +dual-package patch record and now names both inline recovery and unified target-state +text. Versions and `CHANGELOG.md` remain untouched. This plan stays active because +Phase 2 is blocked. + +### Phase 1B implementation and verification record + +Runtime commit `9b3523e` implements the increment against plan baseline `28772a3`. +The replacement worker verified 144 focused presentation/text/status tests and 218 +consumer, parity, CLI/MCP, and smoke-helper tests, plus `bun run typecheck` and +Biome over all 14 owned runtime/test paths. During the interrupted worker handoff, one +test-suite deletion occurred transiently and was restored before those checks passed; +the original worker session also lost authentication and was replaced. No JSON, +schema, backend, skill, instruction, generated asset, or dependency change was made. + +Coordinator-owned full repository/package/build checks, live smoke, targeted agent +evaluation, and fresh external Opus review are not yet recorded as complete. They +remain the final validation/review work for this branch. ### Plan review record @@ -856,8 +874,9 @@ text and inline recovery. Versions and `CHANGELOG.md` remain untouched. unknown states remain conservative. - Healthy output collapses to one target-plus-lanes `Sources:` row; ranked hits and structured JSON remain unchanged. -- Presentation, renderer, CLI/MCP/parity, smoke, full repository, package, and targeted - agent-eval validation pass with evidence recorded in this plan. +- Presentation, renderer, CLI/MCP/parity, and smoke-helper validation pass with the + worker evidence recorded above. Full repository, package/build, live smoke, and + targeted agent-eval validation remain pending coordinator evidence. - Durable implementation docs and the existing dual-package patch fragment reflect the final behavior; package versions, released changelogs, descriptors, skills, generated assets, and backend requests remain unchanged. @@ -934,11 +953,11 @@ build, package, smoke, and agent-eval verification appropriate to changed MCP be ## Phase boundary and completion -Phase 1 is complete in the recorded commits above. Phase 1B is the current ready -increment and stays within the existing client-owned text projection. After Phase 1B -implementation and review, record its commits and observed evidence here before pushing -the updated draft PR. Use a fresh `origin/main` comparison before beginning Phase 2, and -do not mix speculative Phase 2 fields into the client UX increment. +Phase 1 and Phase 1B are complete in the recorded commits above and stay within the +existing client-owned text projection. Coordinator final validation and review remain +pending before the updated draft PR is pushed. Use a fresh `origin/main` comparison +before beginning Phase 2, and do not mix speculative Phase 2 fields into the client UX +increment. This plan remains active while private backend #2133 blocks Phase 2. After both phases are implemented, transfer all lasting contracts to `docs/implementation/`, verify no unresolved work From 920bb870d05616d10b8b8771bf957927dd308d03 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 14:32:44 +0300 Subject: [PATCH 10/24] docs: align search parity guidance Remove the obsolete session row and document the single target-state continuation contract across CLI and MCP. --- docs/implementation/mcp-cli-parity.md | 37 ++++++++++--------- ...rch-client-recovery-and-target-guidance.md | 5 +++ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 00dea1b9..d99b3a9c 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -241,23 +241,23 @@ readiness, trust limits, and action selection; the text renderer owns wording, wrapping, hit anatomy, and ordering. Callers provide ANSI enablement, surface-native action syntax, and an optional output width. CLI supplies its current terminal width; MCP uses the formatter's 80-column default. The order is -outcome headline, target blocks with -identity plus grouped readiness/usable alternatives, warnings and results, an -optional session summary, and one positive next action. +an outcome headline carrying count/breakdown, lifecycle, readiness, and +pagination when applicable; one compact `Sources: - ` row for +ordinary completed current results or one detailed target block per requested +target; target-local state/recovery and global warnings; the separate ranked hit +list; and at most one final `Next:` action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct. Active empty output uses -`Indexing - no results yet`; an active response without a snapshot uses -`Indexing - no result snapshot yet`, with corresponding lifecycle labels for -other active states. Active result counts use `interim` when `partialResults` is -false and `partial` when it is true. When session facts exist, the renderer may -emit one optional session row composed from available facts: `Search ` when -a reference exists, aggregate `/ target(s) ready` when progress -exists, and a lifecycle summary when a reference has no progress. The combined -form is `Search | / target(s) ready`; completed output -without session facts may omit it. A reference appears once in that row when -available and once in the follow-up action when the action carries it. Terminal -and unknown statuses retain their exact status. Site suggestions remain ordered -advisory labels and are never selected automatically. +`No results yet | indexing | 0/1 ready`; an active response without a snapshot +uses `No result snapshot yet | indexing | 0/1 ready`, with corresponding +lower-case lifecycle labels for other active states. Active result counts use +`interim` when `partialResults` is false and `partial` when it is true. Terminal +or unknown progress retains lifecycle/readiness in the headline, while completed +output omits them. Target rows keep deterministic `using`, `searched`, +`indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments; +exact terminal reasons remain lane-readable, and a target gets at most one +inline `Fix:` or replayable `Try:` line. Site suggestions and indexed alternatives +remain attached to their target and are never selected automatically. `evidenceNotice` remains exact in JSON and is not rendered as a generic mutable-evidence slogan. Concrete stale, provisional, pending, and coverage @@ -268,9 +268,10 @@ Reissuing the same search is valid and waits on the same underlying work; text does not emit negative repeat or poll policy directives. MCP renders `Next: search_status search_ref=... wait_timeout_ms=...`; CLI renders -`Next: githits search-status ... --wait ...`. The session row and continuation -action use the same reference when both are present; raw diagnostic fields are -never rendered. Search-result follow-ups likewise use +`Next: githits search-status ... --wait ...`. An active continuation reference +appears exactly once, in that surface-native final `Next:` action; stopped terminal +references are not rendered. Raw diagnostic fields are never rendered. +Search-result follow-ups likewise use `code_read` / `docs_read` in MCP and `githits code read` / `githits docs read` in CLI. ANSI-stripped CLI output shares the same hierarchy and wording as no-color MCP text apart from those supplied command dialects; line breaks can differ diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index 000f327c..ec2560f3 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -828,6 +828,11 @@ Coordinator-owned full repository/package/build checks, live smoke, targeted age evaluation, and fresh external Opus review are not yet recorded as complete. They remain the final validation/review work for this branch. +The final preflight sibling scan found one stale optional session-row and duplicate +`searchRef` description in `docs/implementation/mcp-cli-parity.md`; this closure +updates that parity section to the implemented single-list contract. No runtime or +test behavior changed. + ### Plan review record - Internal technical review accepted two findings: target-local recovery suppresses From 6b4a2d2e9633a72986f0e264efeb41a66e481801 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 14:35:33 +0300 Subject: [PATCH 11/24] fix: tighten search presentation helpers Declare all projected target aliases in the helper return type and compute terminal reasons once per source entry without changing behavior. --- packages/mcp/src/shared/unified-search-presentation.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 270b14dc..a3ec1cf6 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -421,13 +421,12 @@ function projectSources( } const kind = sourceKind(entry); + const terminalReason = sourceTerminalReason(entry); appendSourceEntry(groups, kind, { state: sourceState(entry), ...sourceIdentity(entry, kind), resultCount: entry.resultCount, - ...(sourceTerminalReason(entry) - ? { terminalReason: sourceTerminalReason(entry) } - : {}), + ...(terminalReason ? { terminalReason } : {}), }); } return groups; @@ -518,7 +517,10 @@ function sourceIdentity( function sourceTargetAliases( entry: UnifiedSearchSourceStatusPayload, -): Pick { +): Pick< + UnifiedSearchSourceEntry, + "targetAliases" | "requestedTarget" | "freshTarget" | "servedTarget" +> { const aliases = uniqueAliases([ entry.targetLabel, entry.requestedTarget, From c268cba013f12f793bc473df06bfeadcd7419859 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 14:53:53 +0300 Subject: [PATCH 12/24] fix: preserve local search recovery guidance Normalize latest package display identities before composing replay targets, retain exact terminal recovery alongside healthy peer hits, and let smoke validators recognize target-local Fix/Try actions as actionable empty results. --- .../unified-search-presentation.test.ts | 61 +++++++++++++++++++ .../src/shared/unified-search-presentation.ts | 7 ++- .../src/shared/unified-search-text.test.ts | 50 +++++++++++++++ packages/mcp/src/smoke-test.test.ts | 20 ++++++ packages/mcp/src/smoke-test.ts | 19 ++++++ scripts/cli-smoke.ts | 22 ++++++- scripts/smoke-scripts.test.ts | 13 ++++ 7 files changed, 190 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index f366d53f..c398935c 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -478,6 +478,67 @@ describe("projectUnifiedSearchPresentation", () => { }); }); + it("normalizes latest package display identities for recovery targets", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:express latest", + requestedTarget: "npm:express latest", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + availableVersions: [{ version: "5.1.0", ref: "v5.1.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.targetGroups[0]?.recovery).toEqual({ + kind: "try", + category: "version", + target: "npm:express@5.1.0", + additionalTargets: [], + truncated: false, + }); + }); + + it("keeps terminal recovery for a failed peer beside healthy hits", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "CURRENT", + resultCount: 1, + }), + source({ + targetLabel: "npm:missing@1.0.0", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + ], + }), + ); + + expect( + presentation.targetGroups.map((group) => ({ + target: group.identity.requested, + recovery: group.recovery, + })), + ).toEqual([ + { target: "npm:express@4.18.2", recovery: undefined }, + { + target: "npm:missing@1.0.0", + recovery: { kind: "fix", family: "package" }, + }, + ]); + expect(presentation.action).toEqual({ kind: "none" }); + }); + it("terminal target recovery prefers indexed alternatives without freshness signals", () => { const presentation = projectUnifiedSearchPresentation( completed({ diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index a3ec1cf6..4f206bb8 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1336,6 +1336,11 @@ function projectTargetRecovery( ? fixRecovery(group) : undefined; } + if (lifecycle.kind === "completed" && hasTerminalReason) { + return hasBareTerminalReason + ? undefined + : (candidate ?? fixRecovery(group)); + } if (availability.kind !== "empty" || !snapshot) return undefined; if (hasTerminalReason) { @@ -1502,7 +1507,7 @@ function composePackageTarget( ): string | undefined { if (!version) return undefined; try { - const parsed = parsePackageSpec(identity); + const parsed = parsePackageSpec(identity.trim().replace(/\s+latest$/, "")); return `${parsed.registry}:${parsed.name}@${version}`; } catch { return undefined; diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index fea31d38..d78880dc 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -716,6 +716,56 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("Fix: verify public GitHub repository/ref."); }); + it("renders a canonical recovery target for a latest package display identity", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:express latest", + requestedTarget: "npm:express latest", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + availableVersions: [{ version: "5.1.0", ref: "v5.1.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(text).toBe( + "No results\n\n" + + "- npm:express latest\n" + + " package unresolved: code\n" + + " Try: npm:express@5.1.0", + ); + expect(text).not.toContain("npm:express latest@5.1.0"); + }); + + it("renders terminal peer recovery beside healthy hits without a global Next", () => { + const text = renderUnifiedSearchSuccess( + completed([codeHit({ target: "npm:express@4.18.2" })], { + sourceStatus: [ + source({ + targetLabel: "npm:express@4.18.2", + codeIndexState: "CURRENT", + resultCount: 1, + }), + source({ + targetLabel: "npm:missing@1.0.0", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + ], + }), + ); + + expect(text).toContain("- npm:missing@1.0.0"); + expect(text).toContain("Fix: verify registry coordinate/version"); + expect(text).not.toContain("Next:"); + }); + it.each(["docs", "auto"] as const)( "uses a neutral docs label for contributor-less %s sources", (sourceName) => { diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 173e085b..05a149b5 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -465,6 +465,26 @@ describe("runMcpSmoke", () => { ); }); + it.each([ + [ + "Fix", + "No results\n\n- npm:missing@1.0.0\n Fix: verify the package coordinate.", + ], + ["Try", "No results\n\n- npm:missing latest\n Try: npm:missing@1.0.0"], + ])( + "accepts target-local %s recovery without a hit or Next", + async (_kind, searchText) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult(searchText); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }, + ); + it("rejects duplicate lifecycle outcome lines", async () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 4b5a1d29..72e6d986 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -297,11 +297,30 @@ function assertSearchDefaultText(text: string, context: string): void { ); assert( hasHumanSearchHitLocator(lines) || + hasTargetRecovery(formatterLines) || lines.some((line) => line.startsWith("Next:")), `${context}: missing usable result locator or status follow-up`, ); } +function hasTargetRecovery(lines: string[]): boolean { + return lines.some((line, index) => { + if (!/^ {2}(?:Fix|Try):\s+\S/.test(line)) return false; + for ( + let previousIndex = index - 1; + previousIndex >= 0; + previousIndex -= 1 + ) { + const previous = lines[previousIndex]; + if (!previous || previous.trim() === "") continue; + if (/^-\s+\S/.test(previous)) return true; + if (previous.startsWith(" ")) continue; + return false; + } + return false; + }); +} + function hasHumanSearchHitLocator(lines: string[]): boolean { return lines.some((line, index) => { const docsMatch = /^\[\d+\]\s+(\S+)\s+\[docs page\]\s+(.+)$/.exec(line); diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index b0c3d9c0..808f4614 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -471,11 +471,31 @@ export function assertSearchTerminalText(text: string, context: string): void { `${context}: MCP search_ref syntax leaked into CLI output`, ); assert( - hasHumanSearchHitLocator(lines) || nextLines.length > 0, + hasHumanSearchHitLocator(lines) || + hasTargetRecovery(formatterLines) || + nextLines.length > 0, `${context}: missing result follow-up or next action`, ); } +function hasTargetRecovery(lines: string[]): boolean { + return lines.some((line, index) => { + if (!/^ {2}(?:Fix|Try):\s+\S/.test(line)) return false; + for ( + let previousIndex = index - 1; + previousIndex >= 0; + previousIndex -= 1 + ) { + const previous = lines[previousIndex]; + if (!previous || previous.trim() === "") continue; + if (/^-\s+\S/.test(previous)) return true; + if (previous.startsWith(" ")) continue; + return false; + } + return false; + }); +} + function hasHumanSearchHitLocator(lines: string[]): boolean { return lines.some((line, index) => { const docsMatch = /^\[\d+\]\s+(\S+)\s+\[docs page\]\s+(.+)$/.exec(line); diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index c0c68104..344fd2ac 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -154,6 +154,19 @@ Next: shorten or broaden query; use githits code grep.`; ); }); + it.each([ + [ + "Fix", + "No results\n\n- npm:missing@1.0.0\n Fix: verify the package coordinate.", + ], + ["Try", "No results\n\n- npm:missing latest\n Try: npm:missing@1.0.0"], + ])( + "accepts target-local %s recovery without a hit or Next", + (_kind, text) => { + expect(() => assertSearchTerminalText(text, "search")).not.toThrow(); + }, + ); + it("accepts completed target readiness without a search session", () => { expect(() => assertSearchTerminalText(completedWithTargetReadiness, "search"), From e4cb9600cf7fdd3b2bd1c83db5b7e247832c368c Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 15:08:22 +0300 Subject: [PATCH 13/24] fix: restore terminal search follow-up Keep target-local terminal reasons visible while returning a global rerun action when no local recovery exists. Remove phantom post-hit separators and align MCP and CLI smoke validation with bare readiness states and terminal reason labels. --- .../unified-search-presentation.test.ts | 32 +++++++++++ .../src/shared/unified-search-presentation.ts | 2 +- .../src/shared/unified-search-text.test.ts | 53 +++++++++++++++++++ .../mcp/src/shared/unified-search-text.ts | 3 +- packages/mcp/src/smoke-test.test.ts | 35 ++++++++++++ packages/mcp/src/smoke-test.ts | 6 +-- scripts/cli-smoke.ts | 6 +-- scripts/smoke-scripts.test.ts | 27 ++++++++++ 8 files changed, 155 insertions(+), 9 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index c398935c..e4f4604f 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -2032,6 +2032,38 @@ describe("projectUnifiedSearchPresentation", () => { }); }); + it.each(["TIMEOUT", "FAILED"] as const)( + "reruns a terminal response with a bare terminal lane reason: %s", + (status) => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + progress: { + status, + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + sourceStatus: [ + source({ + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + source: "symbol", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.targetGroups[0]?.recovery).toBeUndefined(); + expect(presentation.action).toEqual({ kind: "new_search" }); + }, + ); + it("uses query rewrite for completed-empty evidence without a reference", () => { const presentation = projectUnifiedSearchPresentation( completed({ results: [], evidenceNotice: "mutable evidence" }), diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 4f206bb8..41be47cd 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1250,7 +1250,7 @@ function projectAction(input: ActionInput): UnifiedSearchAction { if ( (input.lifecycle.kind === "terminal" || input.lifecycle.kind === "unknown") && - (hasLocalRecovery || hasBareTerminalReason) + hasLocalRecovery ) { return { kind: "none" }; } diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index d78880dc..c8c66530 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -289,6 +289,26 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("searchRef="); }); + it("does not append a separator when progress is not rendered after hits", () => { + const payload = completed([codeHit()]); + const presentation = projectUnifiedSearchPresentation(payload); + const text = renderUnifiedSearchPresentationText( + { + ...presentation, + progress: { targetsReady: 1, targetsTotal: 1, elapsedMs: 20 }, + }, + { results: payload.results }, + ); + + expect(text).toBe( + "1 result | 1 repo code hit\n\n" + + "[1] cline/cline@v3.4.2 src/integrations/diff/strategies/multi-search-replace.ts:142-156 [repo code] -\n" + + " applyEdit\n" + + " Search/replace block parser with fuzzy fallback when exact match fails.", + ); + expect(text.endsWith("\n")).toBe(false); + }); + it("uses singular labels for one repository doc and one docs page", () => { const repoText = renderUnifiedSearchSuccess( completed([ @@ -1276,6 +1296,39 @@ describe("renderUnifiedSearchSuccess", () => { ); }); + it("keeps a bare terminal reason and rerun action for terminal mixed lanes", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + partialResults: false, + progress: { + status: "FAILED", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + sourceStatus: [ + source({ + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + source: "symbol", + codeIndexState: "NOT_FOUND", + resultCount: 0, + }), + ], + }), + ); + + expect(text).toBe( + "No results | failed | 0/1 ready\n\n" + + "- npm:express@4.18.2\n" + + " searched: code; not found: symbols\n\n" + + "Next: rerun search later.", + ); + }); + it("omits a singular outcome target when hits span multiple targets", () => { const text = renderUnifiedSearchSuccess( completed([ diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 27d9f983..0f46191f 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -94,8 +94,7 @@ export function renderUnifiedSearchPresentationText( appendUnifiedSearchHits(lines, result.results, settings); } - const hasPostResultBlock = - presentation.progress !== undefined || presentation.action.kind !== "none"; + const hasPostResultBlock = presentation.action.kind !== "none"; if ( result.results.length > 0 && hasPostResultBlock && diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 05a149b5..04b34cf2 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -485,6 +485,41 @@ describe("runMcpSmoke", () => { }, ); + it("accepts terminal target rows with a global rerun action", async () => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + "No results | failed | 0/1 ready\n\n" + + "- npm:express@4.18.2\n" + + " searched: code; not found: symbols\n\n" + + "Next: rerun search later.", + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + + it.each([ + "ready", + "pending", + "provisional", + "older snapshot", + "package not found: code", + ])("recognizes grouped target state detail: %s", async (detail) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + `No results\n\n- npm:express@4.18.2\n ${detail}\n\nNext: rerun search later.`, + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); + }); + it("rejects duplicate lifecycle outcome lines", async () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 72e6d986..819ed435 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -55,6 +55,8 @@ export const EXPECTED_MCP_TOOLS = [ ] as const; const DEFAULT_TEXT_LIMIT = 12_000; +const TARGET_DETAIL_STATE_PATTERN = + /^ {2}(?:(?:indexing|searched|available|unavailable|using):|(?:ready|pending|provisional|older snapshot)$|(?:not found|unresolved|version unavailable|repository ref unresolved|(?:package|repository|site|target) (?:not found|unresolved)):)/; const SMOKE_PACKAGE_VERSION = "5.2.1"; const SMOKE_PACKAGE_TARGET = { registry: "npm", @@ -247,9 +249,7 @@ function assertSearchDefaultText(text: string, context: string): void { ); const hasReadinessText = formatterLines.some((line) => - /^ {2}(?! {2}).*(?:indexing|searched|available|unavailable|using|ready|pending|provisional|older snapshot):/.test( - line, - ), + TARGET_DETAIL_STATE_PATTERN.test(line), ); if (hasReadinessText) { assert( diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index 808f4614..334a7aae 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -62,6 +62,8 @@ interface JsonParityFixture { } const DEFAULT_TEXT_LIMIT = 20_000; +const TARGET_DETAIL_STATE_PATTERN = + /^ {2}(?:(?:indexing|searched|available|unavailable|using):|(?:ready|pending|provisional|older snapshot)$|(?:not found|unresolved|version unavailable|repository ref unresolved|(?:package|repository|site|target) (?:not found|unresolved)):)/; const JSON_PARITY_CONCURRENCY = 2; const SMOKE_PACKAGE_SPEC = "npm:express@5.2.1"; let cliLaunchTarget = SOURCE_CLI_LAUNCH_TARGET; @@ -437,9 +439,7 @@ export function assertSearchTerminalText(text: string, context: string): void { ); const hasReadinessText = formatterLines.some((line) => - /^ {2}(?! {2}).*(?:indexing|searched|available|unavailable|using|ready|pending|provisional|older snapshot):/.test( - line, - ), + TARGET_DETAIL_STATE_PATTERN.test(line), ); if (hasReadinessText) { assert( diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index 344fd2ac..c69ecb1f 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -167,6 +167,33 @@ Next: shorten or broaden query; use githits code grep.`; }, ); + it("accepts terminal target rows with a global rerun action", () => { + expect(() => + assertSearchTerminalText( + "No results | failed | 0/1 ready\n\n" + + "- npm:express@4.18.2\n" + + " searched: code; not found: symbols\n\n" + + "Next: rerun search later.", + "search", + ), + ).not.toThrow(); + }); + + it.each([ + "ready", + "pending", + "provisional", + "older snapshot", + "package not found: code", + ])("recognizes grouped target state detail: %s", (detail) => { + expect(() => + assertSearchTerminalText( + `No results\n\n- npm:express@4.18.2\n ${detail}\n\nNext: rerun search later.`, + "search", + ), + ).not.toThrow(); + }); + it("accepts completed target readiness without a search session", () => { expect(() => assertSearchTerminalText(completedWithTargetReadiness, "search"), From e11154ace5d37b76eac93ff9cef31d956c95247c Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 15:14:08 +0300 Subject: [PATCH 14/24] docs: record final search UX validation Align the active plan with terminal bare-lane rerun semantics, final-head test/build/smoke evidence, runtime closure commits, and the pending Opus follow-up review. --- ...rch-client-recovery-and-target-guidance.md | 82 ++++++++++++++----- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index ec2560f3..c63b5afa 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -7,15 +7,22 @@ - Later phase: Phase 2 — terminal backend failure details (BLOCKED on private backend #2133) - Commits: Phase 1 runtime `56f6003`; Phase 1 guidance/docs `e0057e6`; runtime/preflight fixes `c88194b`, `80f93a2`; privacy/wording review closure `b6c0581`; Phase 1B - plan baseline `28772a3`; Phase 1B runtime `9b3523e`. + plan baseline `28772a3`; Phase 1B runtime `9b3523e` and runtime closure commits + `6b4a2d2`, `c268cba`, `e4cb960`. - Phase 1B worker evidence: 144 focused presentation/text/status tests pass; 218 consumer, parity, CLI/MCP, and smoke-helper tests pass; `bun run typecheck` and the owned-file Biome check pass. One transient worker test-suite deletion mistake was restored before verification. The original worker lost authentication and was replaced; the replacement resumed the existing delta without discarding it. -- Remaining validation: coordinator full-suite, build/package, live smoke, agent-eval, - and fresh external review evidence is still pending; this plan does not claim those - checks complete. +- Final-head validation is complete: the full suite passed 3,550 tests with 11,360 + expectations across 185 files; typecheck, format/lint, root and MCP builds, both + package validators, and all four smoke modes passed. The MCP publish dry-run was + skipped only because version 0.11.2 is already published. Targeted Claude and Codex + agent evaluation passed 4/4 with high confidence and no futile `search_status` + polling; it ran before the final runtime closure commits, whose deterministic shapes + are covered by final-head tests and smoke suites. +- Fresh Opus follow-up review remains pending; round 1 findings are closed, but this + plan does not claim a clean final external review. - Last verified: 2026-08-29 ## Problem and expected outcome @@ -70,8 +77,9 @@ When this plan is complete: the success JSON envelope does not need a compatibility change in Phase 1. - Focused unit, CLI, MCP, parity, and smoke-helper tests cover terminal recovery, distinct symbol readiness, lane-aware warnings, target grouping, compact sources, - lifecycle headlines, and exactly-once continuation. Full repository validation, - live smoke, agent evaluation, and fresh external review remain coordinator work. + lifecycle headlines, and exactly-once continuation. Final-head full-suite, build, + smoke, package, and targeted agent-evaluation evidence is recorded in the Phase 1B + verification record; fresh Opus follow-up remains pending. - `packages/mcp/src/shared/package-spec.ts` validates registry and syntax only. It deliberately does not own backend package identity conventions. @@ -219,7 +227,8 @@ presentation, CLI, MCP tool, and parity layers. 2. **Phase 1B (COMPLETE):** CLI and MCP text expose one token-efficient state list that keeps every transient, terminal, trust, warning, and recovery fact with its target. Runtime commit `9b3523e` and this documentation/release closure record - the implemented contract; coordinator final validation remains pending. + the implemented contract; final-head validation is recorded, with fresh Opus + follow-up review still pending. 3. **Phase 2 (BLOCKED):** CLI and MCP expose an actionable typed cause for terminal `FAILED` sessions after private backend #2133 supplies the contract. @@ -685,13 +694,15 @@ Recovery/action gating is deterministic: | completed empty | searched empty with no target recovery | none | query rewrite | | completed empty | other unavailable/stale/indexing trust without replayable recovery | none | new search | | terminal/unknown session | any target-local recovery, including suggestion-only site recovery | `Try:`/`Fix:` | none | -| terminal/unknown session | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | none; this row takes precedence over generic new search | +| terminal/unknown session | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | new search | | terminal/unknown session | no target-local recovery or exact lane reason | none | new search | Any target-local recovery suppresses global `new_search` and `query_rewrite`, but never -suppresses an active `poll` or completed evidence `status`. This permits a mixed active -response to tell the caller how to repair one terminal target while polling remaining -indexing targets exactly once. +suppresses an active `poll` or completed evidence `status`. Bare terminal reasons are +not local recovery: terminal/unknown sessions therefore use `new_search`, while +completed-empty searched evidence retains its query rewrite. This permits a mixed +active response to tell the caller how to repair one terminal target while polling +remaining indexing targets exactly once. Ownership check: target state belongs to `UnifiedSearchTargetGroup`; lifecycle and continuation belong to `UnifiedSearchPresentation`. The earlier friction came from the @@ -824,9 +835,29 @@ test-suite deletion occurred transiently and was restored before those checks pa the original worker session also lost authentication and was replaced. No JSON, schema, backend, skill, instruction, generated asset, or dependency change was made. -Coordinator-owned full repository/package/build checks, live smoke, targeted agent -evaluation, and fresh external Opus review are not yet recorded as complete. They -remain the final validation/review work for this branch. +Coordinator-owned full repository/package/build checks, live smoke, and targeted +agent evaluation are recorded below. The final Opus follow-up remains pending, so +review closure is not yet clean. + +Runtime closure commits completed the remaining deterministic corrections: + +- `6b4a2d2` corrected the presentation alias return type and reused the computed + terminal reason in source projection. +- `c268cba` normalized `latest` package replay targets, retained terminal recovery + beside healthy peer hits, and made target-local `Fix:`/`Try:` output actionable in + both smoke validators. +- `e4cb960` restored terminal `new_search` for bare lane reasons, removed phantom + post-hit separators, and aligned smoke recognition with bare readiness states and + terminal reason labels. + +At the final head, `bun test` passed 3,550 tests with 11,360 expectations across 185 +files. Typecheck, format/lint, root and MCP builds, both package validators, and all +four smoke modes passed: CLI live (89 steps), MCP live (46), built CLI (18), and built +MCP (7). The MCP publish dry-run was skipped only because version 0.11.2 was already +published. Targeted Claude and Codex agent evaluation passed 4/4 with high confidence +and no futile `search_status` polling; that qualitative workload ran before `c268cba` +and `e4cb960`, so those final shapes are covered by deterministic final-head tests and +smoke suites rather than by a rerun of the qualitative workload. The final preflight sibling scan found one stale optional session-row and duplicate `searchRef` description in `docs/implementation/mcp-cli-parity.md`; this closure @@ -865,6 +896,15 @@ test behavior changed. precedence distinguishes a bare exact lane reason from generic terminal rerun; the terminal no-snapshot example includes readiness; and coordinate recovery requires no searched or indexing lane. No unresolved major finding or product decision remains. +- Internal code review accepted three implementation findings: latest package replay + target normalization, target-local recovery beside healthy peer hits, and smoke + actionability for target-local recovery. `c268cba` closes all three. +- Fresh Opus round 1 accepted F1 (terminal bare-lane dead end), N1 (trailing post-hit + separator), and N4 (smoke recognition of emitted readiness and terminal states). + `e4cb960` closes those findings. The empty-target guard was rejected as unverified, + and the active-site `available:` relabel was rejected because it contradicts the + explicit Phase 1B product decision. Opus follow-up review remains pending; this is + not a clean final review claim. ### Phase 1B acceptance criteria @@ -880,8 +920,10 @@ test behavior changed. - Healthy output collapses to one target-plus-lanes `Sources:` row; ranked hits and structured JSON remain unchanged. - Presentation, renderer, CLI/MCP/parity, and smoke-helper validation pass with the - worker evidence recorded above. Full repository, package/build, live smoke, and - targeted agent-eval validation remain pending coordinator evidence. + final-head evidence recorded above. Full repository, package/build, live smoke, and + targeted Claude/Codex agent-eval validation are complete; the qualitative evaluation + predates the final runtime closure and those shapes are covered by deterministic + tests/smokes. Fresh Opus follow-up review remains pending. - Durable implementation docs and the existing dual-package patch fragment reflect the final behavior; package versions, released changelogs, descriptors, skills, generated assets, and backend requests remain unchanged. @@ -959,10 +1001,10 @@ build, package, smoke, and agent-eval verification appropriate to changed MCP be ## Phase boundary and completion Phase 1 and Phase 1B are complete in the recorded commits above and stay within the -existing client-owned text projection. Coordinator final validation and review remain -pending before the updated draft PR is pushed. Use a fresh `origin/main` comparison -before beginning Phase 2, and do not mix speculative Phase 2 fields into the client UX -increment. +existing client-owned text projection. Final-head validation is recorded, while fresh +Opus follow-up review remains pending before the updated draft PR is pushed. Use a +fresh `origin/main` comparison before beginning Phase 2, and do not mix speculative +Phase 2 fields into the client UX increment. This plan remains active while private backend #2133 blocks Phase 2. After both phases are implemented, transfer all lasting contracts to `docs/implementation/`, verify no unresolved work From 49a6f2333114445437edfe14a9f2994dd373404b Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 15:31:26 +0300 Subject: [PATCH 15/24] fix: close search target-state review gaps Prioritize indexing peers in completed-empty action selection, keep terminal bare-lane alternatives informational, and remove redundant post-hit spacing. Make smoke readiness grouping assertions discriminate valid grouped state from ungrouped details. --- .../unified-search-presentation.test.ts | 72 ++++++++++++++++++ .../src/shared/unified-search-presentation.ts | 11 ++- .../src/shared/unified-search-text.test.ts | 73 +++++++++++++++++++ .../mcp/src/shared/unified-search-text.ts | 9 --- packages/mcp/src/smoke-test.test.ts | 21 ++++++ scripts/smoke-scripts.test.ts | 15 ++++ 6 files changed, 186 insertions(+), 15 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.test.ts b/packages/mcp/src/shared/unified-search-presentation.test.ts index e4f4604f..dd038626 100644 --- a/packages/mcp/src/shared/unified-search-presentation.test.ts +++ b/packages/mcp/src/shared/unified-search-presentation.test.ts @@ -2064,6 +2064,78 @@ describe("projectUnifiedSearchPresentation", () => { }, ); + it("prefers a new search when another completed-empty target is indexing", () => { + const presentation = projectUnifiedSearchPresentation( + completed({ + results: [], + sourceStatus: [ + source({ + targetLabel: "npm:one@1.0.0", + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + targetLabel: "npm:one@1.0.0", + source: "symbol", + codeIndexState: "UNRESOLVABLE", + resultCount: 0, + }), + source({ + targetLabel: "npm:two@2.0.0", + source: "code", + codeIndexState: "INDEXING", + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.targetGroups.map((group) => group.recovery)).toEqual([ + undefined, + undefined, + ]); + expect(presentation.action).toEqual({ kind: "new_search" }); + }); + + it("keeps terminal alternatives informational beside a bare lane reason", () => { + const presentation = projectUnifiedSearchPresentation( + incomplete({ + partialResults: false, + progress: { + status: "FAILED", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + sourceStatus: [ + source({ + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + source: "symbol", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + availableVersions: [{ version: "4.17.0", ref: "v4.17.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(presentation.targetGroups[0]?.recovery).toBeUndefined(); + expect(presentation.targetGroups[0]?.alternatives).toEqual( + expect.objectContaining({ + versions: [{ version: "4.17.0", ref: "v4.17.0" }], + }), + ); + expect(presentation.action).toEqual({ kind: "new_search" }); + }); + it("uses query rewrite for completed-empty evidence without a reference", () => { const presentation = projectUnifiedSearchPresentation( completed({ results: [], evidenceNotice: "mutable evidence" }), diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 41be47cd..895bc2b3 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1265,13 +1265,13 @@ function projectAction(input: ActionInput): UnifiedSearchAction { } if (hasLocalRecovery) return { kind: "none" }; - if (hasBareTerminalReason) { - return projectQueryRewrite(input.snapshot.query); - } const hasIndexing = input.targetGroups.some((group) => groupHasIndexing(group), ); if (hasIndexing) return { kind: "new_search" }; + if (hasBareTerminalReason) { + return projectQueryRewrite(input.snapshot.query); + } if ( input.targetGroups.some((group) => group.trustLimits.some( @@ -1331,10 +1331,9 @@ function projectTargetRecovery( : undefined; } if (lifecycle.kind === "terminal" || lifecycle.kind === "unknown") { + if (hasBareTerminalReason) return undefined; if (candidate) return candidate; - return hasTerminalReason && !hasBareTerminalReason - ? fixRecovery(group) - : undefined; + return hasTerminalReason ? fixRecovery(group) : undefined; } if (lifecycle.kind === "completed" && hasTerminalReason) { return hasBareTerminalReason diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index c8c66530..83174921 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -1329,6 +1329,79 @@ describe("renderUnifiedSearchSuccess", () => { ); }); + it("prefers rerun when a completed-empty peer target is still indexing", () => { + const text = renderUnifiedSearchSuccess( + completed([], { + sourceStatus: [ + source({ + targetLabel: "npm:one@1.0.0", + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + targetLabel: "npm:one@1.0.0", + source: "symbol", + codeIndexState: "UNRESOLVABLE", + resultCount: 0, + }), + source({ + targetLabel: "npm:two@2.0.0", + source: "code", + codeIndexState: "INDEXING", + resultCount: 0, + }), + ], + }), + ); + + expect(text).toBe( + "No results\n\n" + + "- npm:one@1.0.0\n" + + " searched: code; unresolved: symbols\n\n" + + "- npm:two@2.0.0\n" + + " indexing: code\n\n" + + "Next: rerun search later.", + ); + }); + + it("keeps terminal alternatives informational beside a bare lane reason", () => { + const text = renderUnifiedSearchSuccess( + incomplete({ + partialResults: false, + progress: { + status: "FAILED", + targetsReady: 0, + targetsTotal: 1, + elapsedMs: 60_000, + }, + sourceStatus: [ + source({ + source: "code", + codeIndexState: "CURRENT", + resultCount: 0, + }), + source({ + source: "symbol", + codeIndexState: "UNRESOLVABLE", + targetResolution: { + availableVersions: [{ version: "4.17.0", ref: "v4.17.0" }], + availableRefs: [], + }, + resultCount: 0, + }), + ], + }), + ); + + expect(text).toBe( + "No results | failed | 0/1 ready\n\n" + + "- npm:express@4.18.2\n" + + " searched: code; unresolved: symbols; indexed: versions 4.17.0\n\n" + + "Next: rerun search later.", + ); + }); + it("omits a singular outcome target when hits span multiple targets", () => { const text = renderUnifiedSearchSuccess( completed([ diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 0f46191f..3001f5b8 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -94,15 +94,6 @@ export function renderUnifiedSearchPresentationText( appendUnifiedSearchHits(lines, result.results, settings); } - const hasPostResultBlock = presentation.action.kind !== "none"; - if ( - result.results.length > 0 && - hasPostResultBlock && - lines[lines.length - 1] !== "" - ) { - lines.push(""); - } - appendPresentationAction(lines, presentation, settings); return lines.join("\n"); } diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 04b34cf2..502bcb12 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -520,6 +520,27 @@ describe("runMcpSmoke", () => { await expect(runMcpSmoke(caller)).resolves.toBeUndefined(); }); + it.each([ + "ready", + "pending", + "provisional", + "older snapshot", + "package not found: code", + ])("rejects ungrouped target state detail: %s", async (detail) => { + const caller = createCaller(async (name, args) => { + if (name === "search" && args.format !== "json") { + return textResult( + `No results\n ${detail}\n\nNext: rerun search later.`, + ); + } + return smokeResponse(name, args); + }); + + await expect(runMcpSmoke(caller)).rejects.toThrow( + "search default: readiness details must be grouped under a target", + ); + }); + it("rejects duplicate lifecycle outcome lines", async () => { const caller = createCaller(async (name, args) => { if (name === "search" && args.format !== "json") { diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index c69ecb1f..ff8e0ada 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -194,6 +194,21 @@ Next: shorten or broaden query; use githits code grep.`; ).not.toThrow(); }); + it.each([ + "ready", + "pending", + "provisional", + "older snapshot", + "package not found: code", + ])("rejects ungrouped target state detail: %s", (detail) => { + expect(() => + assertSearchTerminalText( + `No results\n ${detail}\n\nNext: rerun search later.`, + "search", + ), + ).toThrow("readiness details must be grouped under a target"); + }); + it("accepts completed target readiness without a search session", () => { expect(() => assertSearchTerminalText(completedWithTargetReadiness, "search"), From 46b91066c903a70786ead7e32881a78a64677639 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 15:37:55 +0300 Subject: [PATCH 16/24] docs: record final search target-state review Document multi-target indexing precedence, Opus round-two closure, and final-head validation after the runtime review fixes. Keep the third and final Opus closure round explicitly pending while Phase 2 remains blocked. --- ...rch-client-recovery-and-target-guidance.md | 78 ++++++++++++------- 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index c63b5afa..f241db32 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -8,21 +8,23 @@ - Commits: Phase 1 runtime `56f6003`; Phase 1 guidance/docs `e0057e6`; runtime/preflight fixes `c88194b`, `80f93a2`; privacy/wording review closure `b6c0581`; Phase 1B plan baseline `28772a3`; Phase 1B runtime `9b3523e` and runtime closure commits - `6b4a2d2`, `c268cba`, `e4cb960`. + `6b4a2d2`, `c268cba`, `e4cb960`, `49a6f23`. - Phase 1B worker evidence: 144 focused presentation/text/status tests pass; 218 consumer, parity, CLI/MCP, and smoke-helper tests pass; `bun run typecheck` and the owned-file Biome check pass. One transient worker test-suite deletion mistake was restored before verification. The original worker lost authentication and was replaced; the replacement resumed the existing delta without discarding it. -- Final-head validation is complete: the full suite passed 3,550 tests with 11,360 - expectations across 185 files; typecheck, format/lint, root and MCP builds, both - package validators, and all four smoke modes passed. The MCP publish dry-run was - skipped only because version 0.11.2 is already published. Targeted Claude and Codex - agent evaluation passed 4/4 with high confidence and no futile `search_status` - polling; it ran before the final runtime closure commits, whose deterministic shapes - are covered by final-head tests and smoke suites. -- Fresh Opus follow-up review remains pending; round 1 findings are closed, but this - plan does not claim a clean final external review. +- Final-head validation after runtime closure commit `49a6f23` is complete: `bun test` + passed 3,564 tests with 11,377 expectations and 0 failures across 185 files; + typecheck, format/lint, root and MCP builds, and both package validations passed. + All four smoke modes passed: CLI live (89 steps), MCP live (46), built CLI (18), + and built MCP (7). The MCP publish dry-run was skipped only because version 0.11.2 + is already published. Targeted Claude and Codex agent evaluation passed 4/4 with + high confidence and no futile `search_status` polling; it ran before the final + runtime closure commits, whose deterministic shapes are covered by final-head tests + and smoke suites. +- The third/final Opus closure round remains pending; rounds 1 and 2 findings are + closed, but this plan does not claim a clean final external review. - Last verified: 2026-08-29 ## Problem and expected outcome @@ -79,7 +81,7 @@ When this plan is complete: distinct symbol readiness, lane-aware warnings, target grouping, compact sources, lifecycle headlines, and exactly-once continuation. Final-head full-suite, build, smoke, package, and targeted agent-evaluation evidence is recorded in the Phase 1B - verification record; fresh Opus follow-up remains pending. + verification record; the third/final Opus closure round remains pending. - `packages/mcp/src/shared/package-spec.ts` validates registry and syntax only. It deliberately does not own backend package identity conventions. @@ -227,8 +229,8 @@ presentation, CLI, MCP tool, and parity layers. 2. **Phase 1B (COMPLETE):** CLI and MCP text expose one token-efficient state list that keeps every transient, terminal, trust, warning, and recovery fact with its target. Runtime commit `9b3523e` and this documentation/release closure record - the implemented contract; final-head validation is recorded, with fresh Opus - follow-up review still pending. + the implemented contract; final-head validation is recorded, with the third/final + Opus closure round still pending. 3. **Phase 2 (BLOCKED):** CLI and MCP expose an actionable typed cause for terminal `FAILED` sessions after private backend #2133 supplies the contract. @@ -697,6 +699,12 @@ Recovery/action gating is deterministic: | terminal/unknown session | exact terminal reason on a target with any searched/indexing lane | none; keep bare reason on affected lane | new search | | terminal/unknown session | no target-local recovery or exact lane reason | none | new search | +Within completed-empty multi-target output, after target-local recovery has taken +precedence, any indexing or pending target forces the global `new search` action +before a bare searched-empty terminal reason on another target can select query +rewrite. For a single target, that bare searched-empty terminal reason still selects +query rewrite. + Any target-local recovery suppresses global `new_search` and `query_rewrite`, but never suppresses an active `poll` or completed evidence `status`. Bare terminal reasons are not local recovery: terminal/unknown sessions therefore use `new_search`, while @@ -836,8 +844,8 @@ the original worker session also lost authentication and was replaced. No JSON, schema, backend, skill, instruction, generated asset, or dependency change was made. Coordinator-owned full repository/package/build checks, live smoke, and targeted -agent evaluation are recorded below. The final Opus follow-up remains pending, so -review closure is not yet clean. +agent evaluation are recorded below. The third/final Opus closure round remains +pending, so review closure is not yet clean. Runtime closure commits completed the remaining deterministic corrections: @@ -849,15 +857,19 @@ Runtime closure commits completed the remaining deterministic corrections: - `e4cb960` restored terminal `new_search` for bare lane reasons, removed phantom post-hit separators, and aligned smoke recognition with bare readiness states and terminal reason labels. - -At the final head, `bun test` passed 3,550 tests with 11,360 expectations across 185 -files. Typecheck, format/lint, root and MCP builds, both package validators, and all -four smoke modes passed: CLI live (89 steps), MCP live (46), built CLI (18), and built -MCP (7). The MCP publish dry-run was skipped only because version 0.11.2 was already -published. Targeted Claude and Codex agent evaluation passed 4/4 with high confidence -and no futile `search_status` polling; that qualitative workload ran before `c268cba` -and `e4cb960`, so those final shapes are covered by deterministic final-head tests and -smoke suites rather than by a rerun of the qualitative workload. +- `49a6f23` prioritized indexing peers over bare-lane query rewrites, gated terminal + candidate recovery behind bare reasons, and made the smoke readiness fixtures + discriminate grouped state details. + +At the final head after `49a6f23`, `bun test` passed 3,564 tests with 11,377 +expectations and 0 failures across 185 files. Typecheck, format/lint, root and MCP +builds, both package validations, and all four smoke modes passed: CLI live (89 +steps), MCP live (46), built CLI (18), and built MCP (7). The MCP publish dry-run was +skipped only because version 0.11.2 was already published. Targeted Claude and Codex +agent evaluation passed 4/4 with high confidence and no futile `search_status` +polling; that qualitative workload ran before `c268cba`, `e4cb960`, and `49a6f23`, so +those final shapes are covered by deterministic final-head tests and smoke suites +rather than by a rerun of the qualitative workload. The final preflight sibling scan found one stale optional session-row and duplicate `searchRef` description in `docs/implementation/mcp-cli-parity.md`; this closure @@ -903,8 +915,15 @@ test behavior changed. separator), and N4 (smoke recognition of emitted readiness and terminal states). `e4cb960` closes those findings. The empty-target guard was rejected as unverified, and the active-site `available:` relabel was rejected because it contradicts the - explicit Phase 1B product decision. Opus follow-up review remains pending; this is - not a clean final review claim. + explicit Phase 1B product decision. These remain rejected as N2 and N3; the + third/final Opus closure round remains pending, so this is not a clean final review + claim. +- Opus round 2 verified all round-1 closures. A permitted fresh-context subreview + surfaced R2-1 (completed-empty indexing-peer precedence) and R2-2 + (terminal/unknown bare-reason candidate gating); Opus independently reproduced and + adjudicated both as accepted. Commit `49a6f23` closes R2-1 and R2-2 plus the + immediate unreachable-branch, redundant-separator, and discriminating smoke-fixture + fixes. The third/final Opus closure round remains pending. ### Phase 1B acceptance criteria @@ -923,7 +942,7 @@ test behavior changed. final-head evidence recorded above. Full repository, package/build, live smoke, and targeted Claude/Codex agent-eval validation are complete; the qualitative evaluation predates the final runtime closure and those shapes are covered by deterministic - tests/smokes. Fresh Opus follow-up review remains pending. + tests/smokes. The third/final Opus closure round remains pending. - Durable implementation docs and the existing dual-package patch fragment reflect the final behavior; package versions, released changelogs, descriptors, skills, generated assets, and backend requests remain unchanged. @@ -1001,8 +1020,9 @@ build, package, smoke, and agent-eval verification appropriate to changed MCP be ## Phase boundary and completion Phase 1 and Phase 1B are complete in the recorded commits above and stay within the -existing client-owned text projection. Final-head validation is recorded, while fresh -Opus follow-up review remains pending before the updated draft PR is pushed. Use a +existing client-owned text projection. Final-head validation is recorded, while the +third/final Opus closure round remains pending before the updated draft PR is pushed. +Use a fresh `origin/main` comparison before beginning Phase 2, and do not mix speculative Phase 2 fields into the client UX increment. From 2c0c9e2150588b37a97a64f105cb04030ceb2303 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 15:42:20 +0300 Subject: [PATCH 17/24] refactor: remove unreachable target recovery branch Delete the dead terminal-reason branch after the lifecycle cases in target recovery. The control flow and rendered behavior remain unchanged; focused recovery and action coverage continues to pass. --- packages/mcp/src/shared/unified-search-presentation.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/mcp/src/shared/unified-search-presentation.ts b/packages/mcp/src/shared/unified-search-presentation.ts index 895bc2b3..0343721e 100644 --- a/packages/mcp/src/shared/unified-search-presentation.ts +++ b/packages/mcp/src/shared/unified-search-presentation.ts @@ -1342,10 +1342,6 @@ function projectTargetRecovery( } if (availability.kind !== "empty" || !snapshot) return undefined; - if (hasTerminalReason) { - if (hasBareTerminalReason) return undefined; - return candidate ?? fixRecovery(group); - } if (site) return site; return groupHasIndexing(group) ? alternative : undefined; } From 9a4140113a2d9990a75a48835399814ade24e058 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Sat, 29 Aug 2026 15:44:22 +0300 Subject: [PATCH 18/24] docs: close final search review loop Record the final Opus round, the behavior-neutral unreachable-branch cleanup, and focused closure evidence. Keep final-head validation counts unchanged and leave Phase 2 blocked on the backend contract. --- ...rch-client-recovery-and-target-guidance.md | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index f241db32..2e87ab4a 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -8,7 +8,7 @@ - Commits: Phase 1 runtime `56f6003`; Phase 1 guidance/docs `e0057e6`; runtime/preflight fixes `c88194b`, `80f93a2`; privacy/wording review closure `b6c0581`; Phase 1B plan baseline `28772a3`; Phase 1B runtime `9b3523e` and runtime closure commits - `6b4a2d2`, `c268cba`, `e4cb960`, `49a6f23`. + `6b4a2d2`, `c268cba`, `e4cb960`, `49a6f23`, `2c0c9e2`. - Phase 1B worker evidence: 144 focused presentation/text/status tests pass; 218 consumer, parity, CLI/MCP, and smoke-helper tests pass; `bun run typecheck` and the owned-file Biome check pass. One transient worker test-suite deletion mistake @@ -23,8 +23,9 @@ high confidence and no futile `search_status` polling; it ran before the final runtime closure commits, whose deterministic shapes are covered by final-head tests and smoke suites. -- The third/final Opus closure round remains pending; rounds 1 and 2 findings are - closed, but this plan does not claim a clean final external review. +- The fresh Opus review loop is complete: round 3 verified all accepted findings are + closed, no further round was warranted under the reviewer/max-three-round loop, + and the final external review is clean. - Last verified: 2026-08-29 ## Problem and expected outcome @@ -81,7 +82,8 @@ When this plan is complete: distinct symbol readiness, lane-aware warnings, target grouping, compact sources, lifecycle headlines, and exactly-once continuation. Final-head full-suite, build, smoke, package, and targeted agent-evaluation evidence is recorded in the Phase 1B - verification record; the third/final Opus closure round remains pending. + verification record; the fresh Opus review loop is complete with all accepted + findings closed. - `packages/mcp/src/shared/package-spec.ts` validates registry and syntax only. It deliberately does not own backend package identity conventions. @@ -229,8 +231,8 @@ presentation, CLI, MCP tool, and parity layers. 2. **Phase 1B (COMPLETE):** CLI and MCP text expose one token-efficient state list that keeps every transient, terminal, trust, warning, and recovery fact with its target. Runtime commit `9b3523e` and this documentation/release closure record - the implemented contract; final-head validation is recorded, with the third/final - Opus closure round still pending. + the implemented contract; final-head validation is recorded, with the fresh Opus + review loop complete and all accepted findings closed. 3. **Phase 2 (BLOCKED):** CLI and MCP expose an actionable typed cause for terminal `FAILED` sessions after private backend #2133 supplies the contract. @@ -844,8 +846,9 @@ the original worker session also lost authentication and was replaced. No JSON, schema, backend, skill, instruction, generated asset, or dependency change was made. Coordinator-owned full repository/package/build checks, live smoke, and targeted -agent evaluation are recorded below. The third/final Opus closure round remains -pending, so review closure is not yet clean. +agent evaluation are recorded below. The fresh Opus review loop is complete, with all +accepted findings closed and no further round warranted under the reviewer/max-three- +round loop. Runtime closure commits completed the remaining deterministic corrections: @@ -857,9 +860,10 @@ Runtime closure commits completed the remaining deterministic corrections: - `e4cb960` restored terminal `new_search` for bare lane reasons, removed phantom post-hit separators, and aligned smoke recognition with bare readiness states and terminal reason labels. -- `49a6f23` prioritized indexing peers over bare-lane query rewrites, gated terminal - candidate recovery behind bare reasons, and made the smoke readiness fixtures - discriminate grouped state details. +- `49a6f23` closed R2-1 and R2-2, removed the redundant post-hit separator, and made + the smoke readiness fixtures discriminate grouped state details. +- `2c0c9e2` removed the genuinely unreachable post-lifecycle terminal-recovery branch; + it had zero runtime impact. At the final head after `49a6f23`, `bun test` passed 3,564 tests with 11,377 expectations and 0 failures across 185 files. Typecheck, format/lint, root and MCP @@ -871,6 +875,11 @@ polling; that qualitative workload ran before `c268cba`, `e4cb960`, and `49a6f23 those final shapes are covered by deterministic final-head tests and smoke suites rather than by a rerun of the qualitative workload. +Focused dead-code closure evidence after `2c0c9e2`: the presentation/text recovery +suite passed 145 tests with 459 expectations across 2 files; `bun run typecheck`, the +owned-file Biome check, and `git diff --check` passed. This behavior-neutral closure +is separate from the unchanged final-head validation above. + The final preflight sibling scan found one stale optional session-row and duplicate `searchRef` description in `docs/implementation/mcp-cli-parity.md`; this closure updates that parity section to the implemented single-list contract. No runtime or @@ -915,15 +924,20 @@ test behavior changed. separator), and N4 (smoke recognition of emitted readiness and terminal states). `e4cb960` closes those findings. The empty-target guard was rejected as unverified, and the active-site `available:` relabel was rejected because it contradicts the - explicit Phase 1B product decision. These remain rejected as N2 and N3; the - third/final Opus closure round remains pending, so this is not a clean final review - claim. + explicit Phase 1B product decision. These remain rejected as N2 and N3. - Opus round 2 verified all round-1 closures. A permitted fresh-context subreview surfaced R2-1 (completed-empty indexing-peer precedence) and R2-2 (terminal/unknown bare-reason candidate gating); Opus independently reproduced and adjudicated both as accepted. Commit `49a6f23` closes R2-1 and R2-2 plus the - immediate unreachable-branch, redundant-separator, and discriminating smoke-fixture - fixes. The third/final Opus closure round remains pending. + redundant-separator and discriminating smoke-fixture fixes. The unreachable branch + is closed separately by `2c0c9e2`. +- Final Opus round 3 reran the round-2 reproductions and verified R2-1 and R2-2, + including their single-target, site-suggestion, and local-recovery carve-outs. It + found only the low-severity unreachable recovery branch and a plan-record mismatch; + the branch had zero runtime impact, was removed in `2c0c9e2`, and this plan now + records the correction. The fresh Opus review loop is complete with all accepted + findings closed; no further round was warranted under the reviewer/max-three-round + loop. ### Phase 1B acceptance criteria @@ -942,7 +956,8 @@ test behavior changed. final-head evidence recorded above. Full repository, package/build, live smoke, and targeted Claude/Codex agent-eval validation are complete; the qualitative evaluation predates the final runtime closure and those shapes are covered by deterministic - tests/smokes. The third/final Opus closure round remains pending. + tests/smokes. The fresh Opus review loop is complete with all accepted findings + closed. - Durable implementation docs and the existing dual-package patch fragment reflect the final behavior; package versions, released changelogs, descriptors, skills, generated assets, and backend requests remain unchanged. @@ -1020,9 +1035,9 @@ build, package, smoke, and agent-eval verification appropriate to changed MCP be ## Phase boundary and completion Phase 1 and Phase 1B are complete in the recorded commits above and stay within the -existing client-owned text projection. Final-head validation is recorded, while the -third/final Opus closure round remains pending before the updated draft PR is pushed. -Use a +existing client-owned text projection. Final-head validation is recorded, and the +fresh Opus review loop is complete with all accepted findings closed before the +updated draft PR is pushed. Use a fresh `origin/main` comparison before beginning Phase 2, and do not mix speculative Phase 2 fields into the client UX increment. From 9364f818fc97e38f9aa288707e6bd064effffaac Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 31 Aug 2026 11:16:13 +0300 Subject: [PATCH 19/24] fix: preserve compact search source provenance Render canonical site locators and compact repository revisions in healthy unified-search output. Keep contributor-less documentation in detailed target state instead of inventing a generic compact source. --- changes/search-client-recovery.fixed.md | 2 +- docs/implementation/cli-commands.md | 2 +- docs/implementation/mcp-cli-parity.md | 10 ++- docs/implementation/tools.md | 10 ++- ...rch-client-recovery-and-target-guidance.md | 15 ++-- .../src/shared/unified-search-text.test.ts | 21 ++++- .../mcp/src/shared/unified-search-text.ts | 84 ++++++++++++++----- src/commands/search.test.ts | 5 +- 8 files changed, 112 insertions(+), 37 deletions(-) diff --git a/changes/search-client-recovery.fixed.md b/changes/search-client-recovery.fixed.md index 235bb888..f214bdc4 100644 --- a/changes/search-client-recovery.fixed.md +++ b/changes/search-client-recovery.fixed.md @@ -3,4 +3,4 @@ "@githits/mcp": patch --- -- **Search recovery and target guidance** - Correct terminal target recovery and canonical package addressing, and unify CLI/MCP text around compact target-state output with inline recovery. +- **Search recovery and target guidance** - Correct terminal target recovery and canonical package addressing, and unify CLI/MCP text around compact target-state output with inline recovery and concrete documentation provenance. diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index d9fe8fdb..f5b4ec8d 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -237,7 +237,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row; any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row: code and symbols use compact lane names, while documentation contributors retain canonical `site:` locators and compact repository revisions. Documentation without concrete provenance stays in detailed target-state form. Any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. The representative CLI n8n active-empty output shape is: diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index d99b3a9c..48a99937 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -242,10 +242,12 @@ wrapping, hit anatomy, and ordering. Callers provide ANSI enablement, surface-native action syntax, and an optional output width. CLI supplies its current terminal width; MCP uses the formatter's 80-column default. The order is an outcome headline carrying count/breakdown, lifecycle, readiness, and -pagination when applicable; one compact `Sources: - ` row for -ordinary completed current results or one detailed target block per requested -target; target-local state/recovery and global warnings; the separate ranked hit -list; and at most one final `Next:` action. +pagination when applicable; one compact `Sources: - ` row for +ordinary completed current results, with canonical site locators and compact +repository revisions; documentation without concrete provenance uses one detailed +target block instead. Other non-compact results likewise use one block per requested +target; target-local state/recovery and global warnings; the separate ranked hit list; +and at most one final `Next:` action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct. Active empty output uses `No results yet | indexing | 0/1 ready`; an active response without a snapshot diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 4e019d66..828647f9 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -154,7 +154,10 @@ advisory rather than aliases; the client never selects or retries one automatica **Unified target-state output.** MCP `search` and `search_status` text-v1 return one outcome-first response. The headline carries result count/type breakdown, active/terminal lifecycle, readiness, and pagination when applicable. A completed -current result set collapses to one `Sources: - ` row; any trust, +current result set collapses to one `Sources: - ` row; code and +symbols use lane names while documentation uses a canonical `site:` +locator or compact repository revision. Documentation without concrete provenance stays +in detailed target-state form. Any trust, warning, alternative, suggestion, or non-current fact keeps every target in one detailed list. Each target row can contain `using`, `searched`, `indexing`, an exact terminal reason, `available`, `indexed`, constraints, and at most one inline @@ -333,8 +336,9 @@ anatomy, and ordering. The order is: 1. one outcome headline with count/breakdown, lifecycle, readiness, and pagination when applicable; -2. one compact `Sources: - ` row for ordinary completed current - results, or one detailed block per target when any state must remain visible; +2. one compact `Sources: - ` row for ordinary completed current + results, retaining concrete documentation provenance when available, or one + detailed block per target when any state must remain visible; 3. target-local state and recovery, then query-wide warnings; 4. the separate numbered ranked hit list; and 5. at most one session/query-wide `Next:` action. diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index 2e87ab4a..21543ca1 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -481,13 +481,15 @@ Ordinary completed, current results collapse all healthy groups to one line: ```text 10 results | 6 repo code hits, 4 docs pages | next_offset=10 -Sources: npm:express@5.2.1 - code, docs +Sources: npm:express@5.2.1 - code, site:expressjs.com, expressjs/express@dbac741a ``` Multiple healthy targets use one semicolon-delimited `Sources:` row, with each target -written once and its searched lanes following it. Repository/site contributor details -are not repeated in this compact row; ranked hit locators retain the concrete evidence -source and JSON retains full provenance. +written once and its searched sources following it. Code and symbols remain compact lane +names. Documentation contributors retain canonical `site:` locators and +compact repository revisions. Compact output requires concrete documentation provenance; +an incomplete payload without it stays in detailed target-state form. Ranked hit locators +and JSON retain their existing provenance. Mixed progress and terminal state use the same target list: @@ -950,8 +952,9 @@ test behavior changed. the sole visible `searchRef`; stopped terminal references are not rendered. - Exact terminal reasons and lanes remain human/agent readable without raw backend prose; unknown states remain conservative. -- Healthy output collapses to one target-plus-lanes `Sources:` row; ranked hits and - structured JSON remain unchanged. +- Healthy output collapses to one target-plus-sources `Sources:` row, retaining concrete + documentation provenance when available; ranked hits and structured JSON remain + unchanged. - Presentation, renderer, CLI/MCP/parity, and smoke-helper validation pass with the final-head evidence recorded above. Full repository, package/build, live smoke, and targeted Claude/Codex agent-eval validation are complete; the qualitative evaluation diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 83174921..14212328 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -258,7 +258,9 @@ describe("renderUnifiedSearchSuccess", () => { expect(text.split("\n")[0]).toBe( "10 results | 5 repo docs, 5 docs pages | next_offset=10", ); - expect(text).toContain("Sources: npm:express@5.2.1 - docs"); + expect(text).toContain( + "Sources: npm:express@5.2.1 - site:expressjs.com, expressjs/express@dbac741a", + ); expect(text).toContain( "[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01", ); @@ -275,6 +277,23 @@ describe("renderUnifiedSearchSuccess", () => { expect(text.length).toBeLessThan(3459); }); + it("keeps contributor-less documentation in detailed target state", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "npm:express@5.2.1" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:express@5.2.1", + resultCount: 1, + }), + ], + }), + ); + + expect(text).toContain("- npm:express@5.2.1\n searched: docs"); + expect(text).not.toContain("Sources:"); + }); + it("starts completed hits with the outcome and preserves hit anatomy", () => { const text = renderUnifiedSearchSuccess(completed([codeHit()])); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 3001f5b8..887afb73 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -318,7 +318,11 @@ function shouldRenderCompactSources( (group.freshnessKind === undefined || group.freshnessKind === "current") && group.sources.every((source) => - source.entries.every((entry) => entry.state === "searched"), + source.entries.every( + (entry) => + entry.state === "searched" && + formatCompactSource(source.kind, entry) !== undefined, + ), ), ); } @@ -332,37 +336,77 @@ function appendCompactSources( const identity = group.identity.served ?? group.identity.fresh ?? group.identity.requested; if (!identity) return []; - const kinds = [ - ...new Set( - group.sources.flatMap((source) => - source.entries - .filter((entry) => entry.state === "searched") - .map(() => compactSourceLane(source.kind)), - ), - ), - ].sort((left, right) => compactLaneRank(left) - compactLaneRank(right)); - return kinds.length > 0 ? [`${identity} - ${kinds.join(", ")}`] : []; + const sources = group.sources + .flatMap((source) => + source.entries + .filter((entry) => entry.state === "searched") + .flatMap((entry) => { + const value = formatCompactSource(source.kind, entry); + return value + ? [{ rank: compactSourceRank(source.kind), value }] + : []; + }), + ) + .sort((left, right) => left.rank - right.rank) + .map((source) => source.value); + const uniqueSources = [...new Set(sources)]; + return uniqueSources.length > 0 + ? [`${identity} - ${uniqueSources.join(", ")}`] + : []; }); const unique = [...new Set(values)]; if (unique.length === 0) return; lines.push(...wrapText(`Sources: ${unique.join("; ")}`, options.width)); } -function compactSourceLane( - kind: UnifiedSearchSourceKind, -): "code" | "symbols" | "docs" { +function compactSourceRank(kind: UnifiedSearchSourceKind): number { switch (kind) { case "code": - return "code"; + return 0; case "symbols": - return "symbols"; - default: - return "docs"; + return 1; + case "site_docs": + return 2; + case "repository_docs": + return 3; + case "docs": + return 4; + } +} + +function formatCompactSource( + kind: UnifiedSearchSourceKind, + entry: UnifiedSearchSourceEntry, +): string | undefined { + if (kind === "code") return "code"; + if (kind === "symbols") return "symbols"; + if (kind === "repository_docs" && entry.repositoryUrl) { + return formatRepositoryIdentity(entry.repositoryUrl, entry.commitSha); + } + if (kind === "site_docs") { + const siteIdentity = formatDocumentationSiteIdentity(entry.siteUrl); + if (siteIdentity) return `site:${siteIdentity}`; + if (entry.target.startsWith("site:")) return entry.target; } + return undefined; } -function compactLaneRank(kind: "code" | "symbols" | "docs"): number { - return kind === "code" ? 0 : kind === "symbols" ? 1 : 2; +function formatRepositoryIdentity(url: string, commitSha?: string): string { + let identity = url; + try { + const parsed = new URL(url); + const path = parsed.pathname + .split("/") + .filter(Boolean) + .join("/") + .replace(/\.git$/, ""); + identity = + parsed.host === "github.com" && path ? path : `${parsed.host}/${path}`; + } catch { + identity = url.replace(/^https?:\/\//, "").replace(/\.git$/, ""); + } + if (!commitSha) return identity; + return `${identity}@${commitSha.slice(0, 8)}`; } function sourceKindRank(kind: UnifiedSearchSourceKind): number { diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 4565bd72..fadbf80b 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -708,7 +708,10 @@ describe("searchAction", () => { const output = String(consoleSpy.mock.calls[0]?.[0]); expect(output.split("\n")[0]).toBe("1 result | 1 docs page"); - expect(output).toContain("Sources: npm:express@5.1.0 - docs"); + expect(output).toContain( + "Sources: npm:express@5.1.0 - site:expressjs.com/en/guide,", + ); + expect(output).toContain("expressjs/express@01234567"); expect(output).toContain( "[1] express/routing [docs page] npm:express - expressjs.com/en/guide/routing.html -\n Routing", ); From bc51e67422ec345978afb8414a15d35293503d1a Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 31 Aug 2026 11:49:08 +0300 Subject: [PATCH 20/24] fix: make compact search sources replayable Use canonical GitHub target syntax, deduplicate standalone source identities, and indent wrapped source continuations. Add direct multi-target coverage for compact provenance grouping. --- docs/implementation/cli-commands.md | 2 +- docs/implementation/mcp-cli-parity.md | 9 +-- docs/implementation/tools.md | 5 +- ...rch-client-recovery-and-target-guidance.md | 9 +-- .../src/shared/unified-search-text.test.ts | 72 ++++++++++++++++++- .../mcp/src/shared/unified-search-text.ts | 41 +++++------ src/commands/search.test.ts | 2 +- 7 files changed, 104 insertions(+), 36 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index f5b4ec8d..b82a950a 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -237,7 +237,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row: code and symbols use compact lane names, while documentation contributors retain canonical `site:` locators and compact repository revisions. Documentation without concrete provenance stays in detailed target-state form. Any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row: code and symbols use compact lane names, while documentation contributors retain canonical `site:` and `github:/#` locators. A source identical to its standalone target is written once. Documentation without concrete provenance stays in detailed target-state form. Any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. The representative CLI n8n active-empty output shape is: diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 48a99937..b62600dc 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -244,10 +244,11 @@ current terminal width; MCP uses the formatter's 80-column default. The order is an outcome headline carrying count/breakdown, lifecycle, readiness, and pagination when applicable; one compact `Sources: - ` row for ordinary completed current results, with canonical site locators and compact -repository revisions; documentation without concrete provenance uses one detailed -target block instead. Other non-compact results likewise use one block per requested -target; target-local state/recovery and global warnings; the separate ranked hit list; -and at most one final `Next:` action. +GitHub revision locators; a source identical to its standalone target is written once. +Documentation without concrete provenance uses one detailed target block instead. Other +non-compact results likewise use one block per requested target; target-local +state/recovery and global warnings; the separate ranked hit list; and at most one final +`Next:` action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct. Active empty output uses `No results yet | indexing | 0/1 ready`; an active response without a snapshot diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 828647f9..c0e34191 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -156,8 +156,9 @@ outcome-first response. The headline carries result count/type breakdown, active/terminal lifecycle, readiness, and pagination when applicable. A completed current result set collapses to one `Sources: - ` row; code and symbols use lane names while documentation uses a canonical `site:` -locator or compact repository revision. Documentation without concrete provenance stays -in detailed target-state form. Any trust, +or `github:/#` locator. A source identical to its standalone +target is written once. Documentation without concrete provenance stays in detailed +target-state form. Any trust, warning, alternative, suggestion, or non-current fact keeps every target in one detailed list. Each target row can contain `using`, `searched`, `indexing`, an exact terminal reason, `available`, `indexed`, constraints, and at most one inline diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index 21543ca1..58b293b0 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -481,15 +481,16 @@ Ordinary completed, current results collapse all healthy groups to one line: ```text 10 results | 6 repo code hits, 4 docs pages | next_offset=10 -Sources: npm:express@5.2.1 - code, site:expressjs.com, expressjs/express@dbac741a +Sources: npm:express@5.2.1 - code, site:expressjs.com, github:expressjs/express#dbac741a ``` Multiple healthy targets use one semicolon-delimited `Sources:` row, with each target written once and its searched sources following it. Code and symbols remain compact lane names. Documentation contributors retain canonical `site:` locators and -compact repository revisions. Compact output requires concrete documentation provenance; -an incomplete payload without it stays in detailed target-state form. Ranked hit locators -and JSON retain their existing provenance. +canonical `github:/#` locators. A source identical to its standalone +target is written once. Compact output requires concrete documentation provenance; an +incomplete payload without it stays in detailed target-state form. Ranked hit locators and +JSON retain their existing provenance. Mixed progress and terminal state use the same target list: diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 14212328..63b0fdea 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -259,7 +259,7 @@ describe("renderUnifiedSearchSuccess", () => { "10 results | 5 repo docs, 5 docs pages | next_offset=10", ); expect(text).toContain( - "Sources: npm:express@5.2.1 - site:expressjs.com, expressjs/express@dbac741a", + "Sources: npm:express@5.2.1 - site:expressjs.com,\n github:expressjs/express#dbac741a", ); expect(text).toContain( "[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01", @@ -294,6 +294,76 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("Sources:"); }); + it("does not repeat a standalone site identity in compact output", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "site:hono.dev" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "site:hono.dev", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 1, + siteUrl: "https://hono.dev", + }, + ], + }), + ], + }), + ); + + expect(text).toContain("Sources: site:hono.dev"); + expect(text).not.toContain("site:hono.dev - site:hono.dev"); + }); + + it("keeps concrete provenance grouped with each healthy target", () => { + const text = renderUnifiedSearchSuccess( + completed( + [ + docsHit({ target: "npm:one@1.0.0" }), + docsHit({ target: "npm:two@2.0.0" }), + ], + { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "npm:one@1.0.0", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 1, + siteUrl: "https://docs.one.example", + }, + ], + }), + source({ + source: "docs", + targetLabel: "npm:two@2.0.0", + contributors: [ + { + kind: "DOCPACK", + state: "SEARCHED", + resultCount: 1, + siteUrl: "https://docs.two.example", + }, + ], + }), + ], + }, + ), + { width: 160 }, + ); + + expect(text).toContain( + "Sources: npm:one@1.0.0 - site:docs.one.example; npm:two@2.0.0 - site:docs.two.example", + ); + expect(text).not.toContain("\n- npm:one@1.0.0"); + expect(text).not.toContain("\n- npm:two@2.0.0"); + }); + it("starts completed hits with the outcome and preserves hit anatomy", () => { const text = renderUnifiedSearchSuccess(completed([codeHit()])); diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 887afb73..48a38d85 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -17,6 +17,7 @@ import { DEFAULT_WAIT_TIMEOUT_MS } from "./code-navigation-defaults.js"; import { colors, dim, highlight, highlightRanges } from "./colors.js"; +import { formatRepositoryTarget } from "./repository-target.js"; import { projectUnifiedSearchPresentation, targetDisplayFamilyKey, @@ -350,13 +351,22 @@ function appendCompactSources( .sort((left, right) => left.rank - right.rank) .map((source) => source.value); const uniqueSources = [...new Set(sources)]; - return uniqueSources.length > 0 - ? [`${identity} - ${uniqueSources.join(", ")}`] - : []; + const distinctSources = uniqueSources.filter( + (source) => source !== identity, + ); + if (distinctSources.length === 0) return [identity]; + return [`${identity} - ${distinctSources.join(", ")}`]; }); const unique = [...new Set(values)]; if (unique.length === 0) return; - lines.push(...wrapText(`Sources: ${unique.join("; ")}`, options.width)); + const wrapped = wrapText( + unique.join("; "), + Math.max(1, options.width - "Sources: ".length), + ); + lines.push( + `Sources: ${wrapped[0] ?? ""}`, + ...wrapped.slice(1).map((line) => ` ${line}`), + ); } function compactSourceRank(kind: UnifiedSearchSourceKind): number { @@ -381,7 +391,10 @@ function formatCompactSource( if (kind === "code") return "code"; if (kind === "symbols") return "symbols"; if (kind === "repository_docs" && entry.repositoryUrl) { - return formatRepositoryIdentity(entry.repositoryUrl, entry.commitSha); + return formatRepositoryTarget( + entry.repositoryUrl, + entry.commitSha?.slice(0, 8), + ); } if (kind === "site_docs") { const siteIdentity = formatDocumentationSiteIdentity(entry.siteUrl); @@ -391,24 +404,6 @@ function formatCompactSource( return undefined; } -function formatRepositoryIdentity(url: string, commitSha?: string): string { - let identity = url; - try { - const parsed = new URL(url); - const path = parsed.pathname - .split("/") - .filter(Boolean) - .join("/") - .replace(/\.git$/, ""); - identity = - parsed.host === "github.com" && path ? path : `${parsed.host}/${path}`; - } catch { - identity = url.replace(/^https?:\/\//, "").replace(/\.git$/, ""); - } - if (!commitSha) return identity; - return `${identity}@${commitSha.slice(0, 8)}`; -} - function sourceKindRank(kind: UnifiedSearchSourceKind): number { switch (kind) { case "code": diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index fadbf80b..128bf8cf 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -711,7 +711,7 @@ describe("searchAction", () => { expect(output).toContain( "Sources: npm:express@5.1.0 - site:expressjs.com/en/guide,", ); - expect(output).toContain("expressjs/express@01234567"); + expect(output).toContain("\n github:expressjs/express#01234567"); expect(output).toContain( "[1] express/routing [docs page] npm:express - expressjs.com/en/guide/routing.html -\n Routing", ); From c423ff5a1bbc471502088e3a20ec79e77d5b7763 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 31 Aug 2026 12:10:09 +0300 Subject: [PATCH 21/24] fix: require pinned repository provenance Keep repository documentation in detailed target state when the backend omits its commit, preventing a mutable locator from replacing a pinned target. --- docs/implementation/cli-commands.md | 2 +- docs/implementation/mcp-cli-parity.md | 6 +-- docs/implementation/tools.md | 5 +- ...rch-client-recovery-and-target-guidance.md | 7 +-- .../src/shared/unified-search-text.test.ts | 53 +++++++++++++++++++ .../mcp/src/shared/unified-search-text.ts | 12 ++++- 6 files changed, 74 insertions(+), 11 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index b82a950a..b30258b8 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -237,7 +237,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row: code and symbols use compact lane names, while documentation contributors retain canonical `site:` and `github:/#` locators. A source identical to its standalone target is written once. Documentation without concrete provenance stays in detailed target-state form. Any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row: code and symbols use compact lane names, while documentation contributors retain canonical `site:` and `github:/#` locators. A source identical to its standalone target is written once; a sole pinned repository source replaces its less-specific repository target. Documentation without concrete provenance stays in detailed target-state form. Any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. The representative CLI n8n active-empty output shape is: diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index b62600dc..7f14787f 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -245,10 +245,10 @@ an outcome headline carrying count/breakdown, lifecycle, readiness, and pagination when applicable; one compact `Sources: - ` row for ordinary completed current results, with canonical site locators and compact GitHub revision locators; a source identical to its standalone target is written once. +A sole pinned repository source replaces its less-specific repository target. Documentation without concrete provenance uses one detailed target block instead. Other -non-compact results likewise use one block per requested target; target-local -state/recovery and global warnings; the separate ranked hit list; and at most one final -`Next:` action. +non-compact results likewise use one block per requested target; target-local state/recovery +and global warnings; the separate ranked hit list; and at most one final `Next:` action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct. Active empty output uses `No results yet | indexing | 0/1 ready`; an active response without a snapshot diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index c0e34191..f7e702aa 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -157,8 +157,9 @@ active/terminal lifecycle, readiness, and pagination when applicable. A complete current result set collapses to one `Sources: - ` row; code and symbols use lane names while documentation uses a canonical `site:` or `github:/#` locator. A source identical to its standalone -target is written once. Documentation without concrete provenance stays in detailed -target-state form. Any trust, +target is written once; a sole pinned repository source replaces its less-specific +repository target. Documentation without concrete provenance stays in detailed target-state +form. Any trust, warning, alternative, suggestion, or non-current fact keeps every target in one detailed list. Each target row can contain `using`, `searched`, `indexing`, an exact terminal reason, `available`, `indexed`, constraints, and at most one inline diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index 58b293b0..f36bd93f 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -488,9 +488,10 @@ Multiple healthy targets use one semicolon-delimited `Sources:` row, with each t written once and its searched sources following it. Code and symbols remain compact lane names. Documentation contributors retain canonical `site:` locators and canonical `github:/#` locators. A source identical to its standalone -target is written once. Compact output requires concrete documentation provenance; an -incomplete payload without it stays in detailed target-state form. Ranked hit locators and -JSON retain their existing provenance. +target is written once; a sole pinned repository source replaces its less-specific +repository target. Compact output requires concrete documentation provenance; an incomplete +payload without it stays in detailed target-state form. Ranked hit locators and JSON retain +their existing provenance. Mixed progress and terminal state use the same target list: diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index 63b0fdea..f034151d 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -318,6 +318,59 @@ describe("renderUnifiedSearchSuccess", () => { expect(text).not.toContain("site:hono.dev - site:hono.dev"); }); + it("uses the sole pinned repository source instead of its unpinned target", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "github:axios/axios" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "github:axios/axios", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/axios/axios", + commitSha: "fede1d1500000000000000000000000000000000", + }, + ], + }), + ], + }), + ); + + expect(text).toContain("Sources: github:axios/axios#fede1d15"); + expect(text).not.toContain( + "github:axios/axios - github:axios/axios#fede1d15", + ); + }); + + it("keeps repository docs without a commit in detailed target state", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "github:axios/axios#main" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "github:axios/axios#main", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/axios/axios", + }, + ], + }), + ], + }), + ); + + expect(text).toContain( + "- github:axios/axios#main\n searched: repository docs", + ); + expect(text).not.toContain("Sources:"); + }); + it("keeps concrete provenance grouped with each healthy target", () => { const text = renderUnifiedSearchSuccess( completed( diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index 48a38d85..f3ae223d 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -355,6 +355,14 @@ function appendCompactSources( (source) => source !== identity, ); if (distinctSources.length === 0) return [identity]; + if ( + uniqueSources.length === 1 && + distinctSources.length === 1 && + targetDisplayFamilyKey(distinctSources[0]) === + targetDisplayFamilyKey(identity) + ) { + return distinctSources; + } return [`${identity} - ${distinctSources.join(", ")}`]; }); const unique = [...new Set(values)]; @@ -390,10 +398,10 @@ function formatCompactSource( ): string | undefined { if (kind === "code") return "code"; if (kind === "symbols") return "symbols"; - if (kind === "repository_docs" && entry.repositoryUrl) { + if (kind === "repository_docs" && entry.repositoryUrl && entry.commitSha) { return formatRepositoryTarget( entry.repositoryUrl, - entry.commitSha?.slice(0, 8), + entry.commitSha.slice(0, 8), ); } if (kind === "site_docs") { From dcbb9f94ef6def308a88fc265debf46bfcb5eafc Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 31 Aug 2026 12:30:21 +0300 Subject: [PATCH 22/24] fix: preserve pinned repository search identity Keep an explicitly pinned repository target beside the resolved commit in compact source provenance while retaining the shorter form for ref-less repository targets. --- docs/implementation/cli-commands.md | 2 +- docs/implementation/mcp-cli-parity.md | 8 +++--- docs/implementation/tools.md | 5 ++-- .../src/shared/unified-search-text.test.ts | 26 +++++++++++++++++++ .../mcp/src/shared/unified-search-text.ts | 2 +- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index b30258b8..a991660c 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -237,7 +237,7 @@ Unified search spans indexed dependency and repository code, docs, and explicit The original unified-search plan envisaged hiding partial mode entirely in v1 to make results trustworthy by default. We kept the flag exposed because some agent and CLI flows benefit from "show me what you have so far." The trust contract is preserved by keeping the default atomic across runnable target/source pairs: callers must explicitly opt into a serveable subset, while any unflagged interim evidence still covers every runnable pair and carries its `searchRef` and freshness signals. -**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row: code and symbols use compact lane names, while documentation contributors retain canonical `site:` and `github:/#` locators. A source identical to its standalone target is written once; a sole pinned repository source replaces its less-specific repository target. Documentation without concrete provenance stays in detailed target-state form. Any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. +**Output.** CLI human output and MCP `text-v1` use one shared outcome-first formatter. The headline combines result count/type breakdown, active or terminal lifecycle, aggregate readiness, and pagination when applicable. Ordinary completed current results collapse to one `Sources: - ` row: code and symbols use compact lane names, while documentation contributors retain canonical `site:` and `github:/#` locators. A source identical to its standalone target is written once; a sole pinned repository source replaces its less-specific ref-less repository target, while an already-pinned target remains beside its resolved commit. Compact repository provenance requires both the repository URL and commit. Documentation without concrete provenance stays in detailed target-state form. Any stale, provisional, coverage, constraint, alternative, suggestion, terminal, or other trust fact keeps every requested target in the same detailed list. Each target identity is followed by deterministic `using`, `searched`, `indexing`, terminal/unavailable, `available`, `indexed`, and constraint segments as applicable. Detailed lanes are `code`, `symbols`, `repository docs`, concrete site docs, and docs. Hits remain a separate numbered ranked evidence list with their follow-up locators: `[1] npm:express@5.2.1 History.md:169-179 [repo doc] - 5.0.0-alpha.4 / 2017-03-01` or `[2] 386050 [docs page] npm:express - expressjs.com/en/4x/api/router/#routerroute - router.route()`. Documentation headers retain the actual page ID required by `docs_read`; formatter-authored punctuation is ASCII and Unicode in backend payloads passes through unchanged. Executable read command lines and qualified internal IDs stay omitted from default text. Active empty output is `No results yet | indexing | 0/1 ready`; no-snapshot output is `No result snapshot yet | indexing | 0/1 ready`, with the corresponding lower-case lifecycle for other active states. Terminal no-snapshot output is `No result snapshot | failed | 0/1 ready`, and completed output omits lifecycle/readiness. Query-wide warnings appear once after target rows and before hits. There is no separate session row: at most one `Next:` line follows the hit list, and an active `searchRef` appears exactly once there. CLI uses `Next: githits search-status --wait 20`; MCP uses its own `search_status` syntax. CLI enables ANSI emphasis when supported, but removing ANSI leaves the same hierarchy and wording apart from surface-native actions; line breaks can differ because CLI uses terminal width while MCP defaults to 80 columns. `--json` emits the shared stable success/error envelope used by MCP `search`, including the full initial `query` echo and exact result-bearing `partialResults` Boolean. JSON remains lossless while text is optimized for agent decisions. The representative CLI n8n active-empty output shape is: diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 7f14787f..bb97a332 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -246,9 +246,11 @@ pagination when applicable; one compact `Sources: - ` row for ordinary completed current results, with canonical site locators and compact GitHub revision locators; a source identical to its standalone target is written once. A sole pinned repository source replaces its less-specific repository target. -Documentation without concrete provenance uses one detailed target block instead. Other -non-compact results likewise use one block per requested target; target-local state/recovery -and global warnings; the separate ranked hit list; and at most one final `Next:` action. +An already-pinned repository target remains beside its resolved commit. Compact repository +provenance requires both its URL and commit; documentation without concrete provenance uses +one detailed target block instead. Other non-compact results likewise use one block per +requested target; target-local state/recovery and global warnings; the separate ranked hit +list; and at most one final `Next:` action. `PENDING`, `INDEXING`, and `SEARCHING` remain distinct. Active empty output uses `No results yet | indexing | 0/1 ready`; an active response without a snapshot diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index f7e702aa..19f9cc66 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -158,8 +158,9 @@ current result set collapses to one `Sources: - ` row; code an symbols use lane names while documentation uses a canonical `site:` or `github:/#` locator. A source identical to its standalone target is written once; a sole pinned repository source replaces its less-specific -repository target. Documentation without concrete provenance stays in detailed target-state -form. Any trust, +ref-less repository target, while an already-pinned target remains beside its resolved +commit. Compact repository provenance requires both the repository URL and commit. +Documentation without concrete provenance stays in detailed target-state form. Any trust, warning, alternative, suggestion, or non-current fact keeps every target in one detailed list. Each target row can contain `using`, `searched`, `indexing`, an exact terminal reason, `available`, `indexed`, constraints, and at most one inline diff --git a/packages/mcp/src/shared/unified-search-text.test.ts b/packages/mcp/src/shared/unified-search-text.test.ts index f034151d..559b0d34 100644 --- a/packages/mcp/src/shared/unified-search-text.test.ts +++ b/packages/mcp/src/shared/unified-search-text.test.ts @@ -345,6 +345,32 @@ describe("renderUnifiedSearchSuccess", () => { ); }); + it("keeps a pinned repository target beside its resolved commit", () => { + const text = renderUnifiedSearchSuccess( + completed([docsHit({ target: "github:axios/axios#v1.7.9" })], { + sourceStatus: [ + source({ + source: "docs", + targetLabel: "github:axios/axios#v1.7.9", + contributors: [ + { + kind: "REPOSITORY_DOCS", + state: "SEARCHED", + resultCount: 1, + repositoryUrl: "https://github.com/axios/axios", + commitSha: "b2cb45d500000000000000000000000000000000", + }, + ], + }), + ], + }), + ); + + expect(text).toContain( + "Sources: github:axios/axios#v1.7.9 - github:axios/axios#b2cb45d5", + ); + }); + it("keeps repository docs without a commit in detailed target state", () => { const text = renderUnifiedSearchSuccess( completed([docsHit({ target: "github:axios/axios#main" })], { diff --git a/packages/mcp/src/shared/unified-search-text.ts b/packages/mcp/src/shared/unified-search-text.ts index f3ae223d..519347a5 100644 --- a/packages/mcp/src/shared/unified-search-text.ts +++ b/packages/mcp/src/shared/unified-search-text.ts @@ -357,7 +357,7 @@ function appendCompactSources( if (distinctSources.length === 0) return [identity]; if ( uniqueSources.length === 1 && - distinctSources.length === 1 && + !identity.includes("#") && targetDisplayFamilyKey(distinctSources[0]) === targetDisplayFamilyKey(identity) ) { From 60fdd40fb2eb227d80ef3b7958ba64dbbdeccda9 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 31 Aug 2026 12:32:17 +0300 Subject: [PATCH 23/24] docs: record compact provenance verification Document the final source shapes, review closure, agent evaluation, and the unrelated live-smoke timeout without overstating final-head coverage. --- ...rch-client-recovery-and-target-guidance.md | 72 ++++++++++++++----- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/docs/plans/search-client-recovery-and-target-guidance.md b/docs/plans/search-client-recovery-and-target-guidance.md index f36bd93f..2b157984 100644 --- a/docs/plans/search-client-recovery-and-target-guidance.md +++ b/docs/plans/search-client-recovery-and-target-guidance.md @@ -8,7 +8,8 @@ - Commits: Phase 1 runtime `56f6003`; Phase 1 guidance/docs `e0057e6`; runtime/preflight fixes `c88194b`, `80f93a2`; privacy/wording review closure `b6c0581`; Phase 1B plan baseline `28772a3`; Phase 1B runtime `9b3523e` and runtime closure commits - `6b4a2d2`, `c268cba`, `e4cb960`, `49a6f23`, `2c0c9e2`. + `6b4a2d2`, `c268cba`, `e4cb960`, `49a6f23`, `2c0c9e2`; compact provenance + follow-up commits `9364f81`, `bc51e67`, `c423ff5`, and `dcbb9f9`. - Phase 1B worker evidence: 144 focused presentation/text/status tests pass; 218 consumer, parity, CLI/MCP, and smoke-helper tests pass; `bun run typecheck` and the owned-file Biome check pass. One transient worker test-suite deletion mistake @@ -23,10 +24,11 @@ high confidence and no futile `search_status` polling; it ran before the final runtime closure commits, whose deterministic shapes are covered by final-head tests and smoke suites. -- The fresh Opus review loop is complete: round 3 verified all accepted findings are - closed, no further round was warranted under the reviewer/max-three-round loop, - and the final external review is clean. -- Last verified: 2026-08-29 +- The original fresh Opus review loop is complete and externally clean. The compact + provenance follow-up used the maximum three Opus rounds; round 3 found one pinned-target + identity defect, closed in `dcbb9f9`. A final internal changed-delta review + found no remaining issue; no fourth external round was run. +- Last verified: 2026-08-31 ## Problem and expected outcome @@ -489,9 +491,10 @@ written once and its searched sources following it. Code and symbols remain comp names. Documentation contributors retain canonical `site:` locators and canonical `github:/#` locators. A source identical to its standalone target is written once; a sole pinned repository source replaces its less-specific -repository target. Compact output requires concrete documentation provenance; an incomplete -payload without it stays in detailed target-state form. Ranked hit locators and JSON retain -their existing provenance. +ref-less repository target. An already-pinned repository target remains beside its resolved +commit. Compact repository provenance requires both the repository URL and commit; other +documentation without concrete provenance stays in detailed target-state form. Ranked hit +locators and JSON retain their existing provenance. Mixed progress and terminal state use the same target list: @@ -884,6 +887,31 @@ suite passed 145 tests with 459 expectations across 2 files; `bun run typecheck` owned-file Biome check, and `git diff --check` passed. This behavior-neutral closure is separate from the unchanged final-head validation above. +The compact provenance follow-up implements the user-requested physical source display: +healthy documentation sources now render canonical `site:` and pinned +`github:/#` locators instead of the generic `docs` lane. A direct +ref-less repository target collapses to its sole pinned source; a pinned target remains +beside the resolved commit. Contributor-less documentation remains detailed because no +healthy live response shape was found that could truthfully supply compact provenance. + +At `dcbb9f9`, the focused formatter/CLI suite passed 153 tests with 560 expectations; +`bun test` passed 3,570 tests with 11,390 expectations and 0 failures; build, typecheck, +lint, format check, and `git diff --check` passed. Source and built live searches produced +`Sources: npm:express@5.2.1 - site:expressjs.com, github:expressjs/express#dbac741a` +and preserved the explicit mapping +`Sources: github:axios/axios#v1.7.9 - github:axios/axios#b2cb45d5`. All four smoke modes +passed at `bc51e67`. After the two subsequent repository-provenance fixes, the final +CLI smoke attempt stopped before its search assertions because the unrelated +`get_example` request timed out after 240 seconds; no retry or weakened assertion was +added. Direct source and built search runs passed after the final fix. + +The targeted Codex descriptor-profile agent evaluation at +`.agent-eval/runs/2026-08-31T09-21-05-040Z` completed successfully with high confidence, +eight logical MCP calls, no CLI fallback, and no instruction issue. Search provided +useful discovery evidence and the agent correctly used normal `next_offset` pagination +without inventing a `search_status` action. One broad `docs_read` range was unrelated; +a targeted follow-up read resolved it. + The final preflight sibling scan found one stale optional session-row and duplicate `searchRef` description in `docs/implementation/mcp-cli-parity.md`; this closure updates that parity section to the implemented single-list contract. No runtime or @@ -942,6 +970,16 @@ test behavior changed. records the correction. The fresh Opus review loop is complete with all accepted findings closed; no further round was warranted under the reviewer/max-three-round loop. +- The compact provenance follow-up used a separate fresh Opus review loop. Round 1 + required replayable GitHub locators, direct-site deduplication, and hanging indentation; + `bc51e67` closed them. Round 2 required collapsing a ref-less direct repository target + to its sole pinned physical source; `c423ff5` also closed an internal finding by + requiring both repository URL and commit for compact provenance. Round 3 found that + the same-family collapse also erased an explicitly pinned request identity. The + suggested target-only remedy was rejected because it would discard the physical commit + provenance required by the user; `dcbb9f9` instead preserves both sides of that mapping. + The three-round external cap was reached, and the final internal changed-delta review + found no remaining finding. ### Phase 1B acceptance criteria @@ -958,11 +996,13 @@ test behavior changed. documentation provenance when available; ranked hits and structured JSON remain unchanged. - Presentation, renderer, CLI/MCP/parity, and smoke-helper validation pass with the - final-head evidence recorded above. Full repository, package/build, live smoke, and - targeted Claude/Codex agent-eval validation are complete; the qualitative evaluation - predates the final runtime closure and those shapes are covered by deterministic - tests/smokes. The fresh Opus review loop is complete with all accepted findings - closed. + final-head evidence recorded above. Full repository and build validation pass. All + four live smoke modes passed before the final two repository-only provenance fixes; + final source/built search probes pass, while the final CLI suite was blocked before + search by the recorded unrelated `get_example` timeout. Targeted agent-eval validation + is complete. Both fresh Opus loops exhausted their permitted review flow with all + accepted findings closed; the provenance loop's last post-fix check is internal because + its third external round found the final defect. - Durable implementation docs and the existing dual-package patch fragment reflect the final behavior; package versions, released changelogs, descriptors, skills, generated assets, and backend requests remain unchanged. @@ -1040,9 +1080,9 @@ build, package, smoke, and agent-eval verification appropriate to changed MCP be ## Phase boundary and completion Phase 1 and Phase 1B are complete in the recorded commits above and stay within the -existing client-owned text projection. Final-head validation is recorded, and the -fresh Opus review loop is complete with all accepted findings closed before the -updated draft PR is pushed. Use a +existing client-owned text projection. Final-head validation and the live-smoke limitation +are recorded, and both fresh Opus loops completed their permitted rounds with all accepted +findings closed before the updated draft PR is pushed. Use a fresh `origin/main` comparison before beginning Phase 2, and do not mix speculative Phase 2 fields into the client UX increment. From ec19c73559207aa8d61e2902a2cc904435fae144 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Mon, 31 Aug 2026 12:57:53 +0300 Subject: [PATCH 24/24] test: allow bounded Windows auth probe Give the container integration test the same timeout budget as other tests that exercise the production PowerShell process-identity probe. --- src/container.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/container.test.ts b/src/container.test.ts index 31bb7495..345010e5 100644 --- a/src/container.test.ts +++ b/src/container.test.ts @@ -292,7 +292,7 @@ describe("createContainer", () => { globalThis.fetch = originalFetch; await rm(storageRoot, { recursive: true, force: true }); } - }); + }, 20_000); it("rejects insecure service URLs before constructing authenticated clients", async () => { await withEnvVars(