@adrkit/mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @adrkit/mcp — the `get_decision_context` tool (US2, contracts/tools.md §6).
3
+ *
4
+ * Reports governing, active-proposal, and historical records for logical file
5
+ * paths using the existing `resolveAffects` resolver, once per record, without ever
6
+ * reading a caller-supplied path. One canonical flat walk is paginated, then the
7
+ * page is partitioned by status.
8
+ */
9
+
10
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { resolveAffects, type Adr, type Finding, type FiredMatcher } from '@adrkit/core';
12
+ import { compareCodeUnits, sortFindingsCanonical } from '../corpus/ordering.ts';
13
+ import { paginate, queryShapeHash } from '../pagination/cursor.ts';
14
+ import {
15
+ ANNOTATIONS,
16
+ corpusHealthOf,
17
+ getDecisionContextInputSchema,
18
+ getDecisionContextOutputSchema,
19
+ invalidCursor,
20
+ loadProjection,
21
+ renderResponseText,
22
+ structuredResult,
23
+ toRelationRefs,
24
+ toSummary,
25
+ type ContextEntry,
26
+ type GetDecisionContextResult,
27
+ type ToolConfig,
28
+ } from './shared.ts';
29
+
30
+ interface GetDecisionContextArgs {
31
+ readonly files: string[];
32
+ readonly cursor?: string;
33
+ readonly limit: number;
34
+ readonly findingsCursor?: string;
35
+ readonly findingsLimit: number;
36
+ }
37
+
38
+ type Bucket = 'governing' | 'activeProposals' | 'history';
39
+
40
+ function bucketFor(status: string): Bucket {
41
+ if (status === 'accepted') return 'governing';
42
+ if (status === 'draft' || status === 'proposed') return 'activeProposals';
43
+ return 'history';
44
+ }
45
+
46
+ function contextEntry(record: Adr, firedMatchers: readonly FiredMatcher[]): ContextEntry {
47
+ return { ...toSummary(record), firedMatchers, relations: toRelationRefs(record.frontmatter) };
48
+ }
49
+
50
+ export function registerGetDecisionContext(server: McpServer, config: ToolConfig): void {
51
+ server.registerTool(
52
+ 'get_decision_context',
53
+ {
54
+ title: 'Get decision context for files',
55
+ description:
56
+ 'Given repo-relative logical file paths, report which decisions govern them, which active proposals touch them, and which historical records once did — using the corpus\u2019s own affects matchers. The supplied paths are compared against patterns only; they are never opened or read.',
57
+ inputSchema: getDecisionContextInputSchema(),
58
+ outputSchema: getDecisionContextOutputSchema(),
59
+ annotations: ANNOTATIONS,
60
+ },
61
+ async (args) => {
62
+ const { files, cursor, limit, findingsCursor, findingsLimit } = args as GetDecisionContextArgs;
63
+
64
+ const loaded = await loadProjection(config);
65
+ if (!loaded.ok) {
66
+ return structuredResult(
67
+ loaded.outcome,
68
+ renderResponseText({ kind: 'corpus-unavailable', reason: loaded.outcome.reason }),
69
+ undefined,
70
+ );
71
+ }
72
+ const projection = loaded.projection;
73
+ const health = corpusHealthOf(projection);
74
+ const fp = projection.fingerprint;
75
+
76
+ const canonicalFiles = [...new Set(files)].sort(compareCodeUnits);
77
+ const primaryQh = queryShapeHash([canonicalFiles, limit]);
78
+ const findingsQh = queryShapeHash([canonicalFiles, findingsLimit]);
79
+
80
+ // Once per record, in canonical order. Derived findings are concatenated.
81
+ const flat: ContextEntry[] = [];
82
+ const derivedFindings: Finding[] = [];
83
+ for (const record of projection.records) {
84
+ const resolved = resolveAffects({ records: [record], changedFiles: files });
85
+ derivedFindings.push(...resolved.findings);
86
+ const match = resolved.matches[0];
87
+ if (match) flat.push(contextEntry(record, match.firedMatchers));
88
+ }
89
+
90
+ const responseFindings = sortFindingsCanonical([...projection.corpusFindings, ...derivedFindings]);
91
+
92
+ const primary = paginate({ items: flat, cursor, limit, scope: 'context.results', fp, qh: primaryQh });
93
+ if (!primary.ok) return invalid(primary.reason);
94
+ const findingsPage = paginate({
95
+ items: responseFindings,
96
+ cursor: findingsCursor,
97
+ limit: findingsLimit,
98
+ scope: 'context.findings',
99
+ fp,
100
+ qh: findingsQh,
101
+ });
102
+ if (!findingsPage.ok) return invalid(findingsPage.reason);
103
+
104
+ const governing: ContextEntry[] = [];
105
+ const activeProposals: ContextEntry[] = [];
106
+ const history: ContextEntry[] = [];
107
+ for (const entry of primary.page.items) {
108
+ const target = bucketFor(entry.status) === 'governing' ? governing : bucketFor(entry.status) === 'activeProposals' ? activeProposals : history;
109
+ target.push(entry);
110
+ }
111
+
112
+ const result: GetDecisionContextResult = {
113
+ outcome: 'matches',
114
+ governing,
115
+ activeProposals,
116
+ history,
117
+ cursor: primary.page.cursor,
118
+ findings: findingsPage.page,
119
+ };
120
+ return structuredResult(
121
+ result,
122
+ renderResponseText({
123
+ kind: 'matches',
124
+ governing: governing.length,
125
+ activeProposals: activeProposals.length,
126
+ history: history.length,
127
+ findings: findingsPage.page.items.length,
128
+ }),
129
+ health,
130
+ );
131
+
132
+ function invalid(reason: Parameters<typeof invalidCursor>[0]) {
133
+ return structuredResult(invalidCursor(reason), renderResponseText({ kind: 'invalid-cursor', reason }), health);
134
+ }
135
+ },
136
+ );
137
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @adrkit/mcp — the `get_decision` tool (US1, contracts/tools.md §4).
3
+ *
4
+ * Fetches one within-limit decision by ref. `parseAdrRef` detects a log-qualified
5
+ * ref first (→ federated-log-unavailable, never a local substitute); a bare id is
6
+ * resolved through the fresh local `byId` bucket into found / not-found /
7
+ * ambiguous-local-id. Relation refs are surfaced verbatim, never expanded.
8
+ */
9
+
10
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { parseAdrRef, type Adr } from '@adrkit/core';
12
+ import { paginate, queryShapeHash, checkInapplicablePrimaryCursor } from '../pagination/cursor.ts';
13
+ import {
14
+ ANNOTATIONS,
15
+ corpusHealthOf,
16
+ getDecisionInputSchema,
17
+ getDecisionOutputSchema,
18
+ invalidCursor,
19
+ loadProjection,
20
+ renderResponseText,
21
+ structuredResult,
22
+ toSummary,
23
+ type DecisionSummary,
24
+ type FullDecision,
25
+ type GetDecisionResult,
26
+ type ToolConfig,
27
+ } from './shared.ts';
28
+
29
+ interface GetDecisionArgs {
30
+ readonly ref: string;
31
+ readonly cursor?: string;
32
+ readonly limit: number;
33
+ readonly findingsCursor?: string;
34
+ readonly findingsLimit: number;
35
+ }
36
+
37
+ function fullDecision(record: Adr, ref: string): FullDecision {
38
+ return {
39
+ requestedRef: ref,
40
+ id: record.frontmatter.id,
41
+ title: record.frontmatter.title,
42
+ status: record.frontmatter.status,
43
+ sourcePath: record.path,
44
+ frontmatter: record.frontmatter,
45
+ body: record.body,
46
+ };
47
+ }
48
+
49
+ export function registerGetDecision(server: McpServer, config: ToolConfig): void {
50
+ server.registerTool(
51
+ 'get_decision',
52
+ {
53
+ title: 'Get a decision by ref',
54
+ description:
55
+ 'Fetch one architecture decision by its bare local id (e.g. "0042"). Returns the complete typed frontmatter and Markdown body, or an explicit not-found / ambiguous-local-id / federated-log-unavailable outcome. Relation refs are surfaced verbatim, never expanded.',
56
+ inputSchema: getDecisionInputSchema(),
57
+ outputSchema: getDecisionOutputSchema(),
58
+ annotations: ANNOTATIONS,
59
+ },
60
+ async (args) => {
61
+ const { ref, cursor, limit, findingsCursor, findingsLimit } = args as GetDecisionArgs;
62
+
63
+ const loaded = await loadProjection(config);
64
+ if (!loaded.ok) {
65
+ return structuredResult(
66
+ loaded.outcome,
67
+ renderResponseText({ kind: 'corpus-unavailable', reason: loaded.outcome.reason }),
68
+ undefined,
69
+ );
70
+ }
71
+ const projection = loaded.projection;
72
+ const health = corpusHealthOf(projection);
73
+ const fp = projection.fingerprint;
74
+ const responseFindings = projection.corpusFindings; // get_decision derives none.
75
+
76
+ // Findings channel (always applicable).
77
+ const findingsPage = paginate({
78
+ items: responseFindings,
79
+ cursor: findingsCursor,
80
+ limit: findingsLimit,
81
+ scope: 'get_decision.findings',
82
+ fp,
83
+ qh: queryShapeHash([findingsLimit]),
84
+ });
85
+
86
+ const parsed = parseAdrRef(ref);
87
+ const candidatesQh = queryShapeHash([ref, limit]);
88
+
89
+ // Log-qualified refs are federated-log-unavailable; byId is never consulted.
90
+ if (parsed.log !== undefined) {
91
+ const inapplicable = checkInapplicablePrimaryCursor({ cursor, scope: 'get_decision.candidates', fp, qh: candidatesQh });
92
+ if (!inapplicable.ok) return invalidResult(inapplicable.reason, health);
93
+ if (!findingsPage.ok) return invalidResult(findingsPage.reason, health);
94
+ const result: GetDecisionResult = {
95
+ outcome: 'federated-log-unavailable',
96
+ requestedRef: ref,
97
+ log: parsed.log,
98
+ id: parsed.id,
99
+ findings: findingsPage.page,
100
+ };
101
+ return structuredResult(
102
+ result,
103
+ renderResponseText({ kind: 'federated-log-unavailable', requestedRef: ref, findings: findingsPage.page.items.length }),
104
+ health,
105
+ );
106
+ }
107
+
108
+ const bucket = projection.byId.get(parsed.id) ?? [];
109
+
110
+ if (bucket.length > 1) {
111
+ const candidates: DecisionSummary[] = bucket.map(toSummary);
112
+ const primary = paginate({
113
+ items: candidates,
114
+ cursor,
115
+ limit,
116
+ scope: 'get_decision.candidates',
117
+ fp,
118
+ qh: candidatesQh,
119
+ });
120
+ if (!primary.ok) return invalidResult(primary.reason, health);
121
+ if (!findingsPage.ok) return invalidResult(findingsPage.reason, health);
122
+ const result: GetDecisionResult = {
123
+ outcome: 'ambiguous-local-id',
124
+ requestedRef: ref,
125
+ candidates: primary.page.items,
126
+ cursor: primary.page.cursor,
127
+ findings: findingsPage.page,
128
+ };
129
+ return structuredResult(
130
+ result,
131
+ renderResponseText({
132
+ kind: 'ambiguous-local-id',
133
+ candidatesLength: primary.page.items.length,
134
+ requestedRef: ref,
135
+ findings: findingsPage.page.items.length,
136
+ }),
137
+ health,
138
+ );
139
+ }
140
+
141
+ // found / not-found: the primary channel does not exist.
142
+ const inapplicable = checkInapplicablePrimaryCursor({ cursor, scope: 'get_decision.candidates', fp, qh: candidatesQh });
143
+ if (!inapplicable.ok) return invalidResult(inapplicable.reason, health);
144
+ if (!findingsPage.ok) return invalidResult(findingsPage.reason, health);
145
+
146
+ if (bucket.length === 1) {
147
+ const record = bucket[0] as Adr;
148
+ const result: GetDecisionResult = { outcome: 'found', decision: fullDecision(record, ref), findings: findingsPage.page };
149
+ return structuredResult(
150
+ result,
151
+ renderResponseText({ kind: 'found', id: record.frontmatter.id, findings: findingsPage.page.items.length }),
152
+ health,
153
+ );
154
+ }
155
+
156
+ const result: GetDecisionResult = { outcome: 'not-found', requestedRef: ref, findings: findingsPage.page };
157
+ return structuredResult(
158
+ result,
159
+ renderResponseText({ kind: 'not-found', requestedRef: ref, findings: findingsPage.page.items.length }),
160
+ health,
161
+ );
162
+
163
+ function invalidResult(reason: Parameters<typeof invalidCursor>[0], corpusHealth: ReturnType<typeof corpusHealthOf>) {
164
+ const outcome = invalidCursor(reason);
165
+ return structuredResult(outcome, renderResponseText({ kind: 'invalid-cursor', reason }), corpusHealth);
166
+ }
167
+ },
168
+ );
169
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @adrkit/mcp — the `list_superseded` tool (US4, contracts/tools.md §7).
3
+ *
4
+ * Lists every superseded record with its DIRECT local replacement state, resolving
5
+ * only unqualified local targets through the fresh `byId` bucket. Never walks
6
+ * lineage, never embeds candidate arrays, and mints only the two specified derived
7
+ * finding templates.
8
+ */
9
+
10
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import { parseAdrRef, type Adr, type Finding } from '@adrkit/core';
12
+ import { sortFindingsCanonical } from '../corpus/ordering.ts';
13
+ import { paginate, queryShapeHash } from '../pagination/cursor.ts';
14
+ import {
15
+ ANNOTATIONS,
16
+ corpusHealthOf,
17
+ invalidCursor,
18
+ loadProjection,
19
+ renderResponseText,
20
+ listSupersededInputSchema,
21
+ listSupersededOutputSchema,
22
+ structuredResult,
23
+ toSummary,
24
+ type ListSupersededResult,
25
+ type SupersededByState,
26
+ type SupersededEntry,
27
+ type ToolConfig,
28
+ } from './shared.ts';
29
+
30
+ interface ListSupersededArgs {
31
+ readonly cursor?: string;
32
+ readonly limit: number;
33
+ readonly findingsCursor?: string;
34
+ readonly findingsLimit: number;
35
+ }
36
+
37
+ interface ResolvedEntry {
38
+ readonly entry: SupersededEntry;
39
+ readonly derived: Finding | undefined;
40
+ }
41
+
42
+ function resolveEntry(record: Adr, byId: ReadonlyMap<string, readonly Adr[]>): ResolvedEntry {
43
+ // The schema invariant guarantees a superseded record has supersededBy.
44
+ const targetRef = record.frontmatter.supersededBy as string;
45
+ const parsed = parseAdrRef(targetRef);
46
+ const summary = toSummary(record);
47
+
48
+ if (parsed.log !== undefined) {
49
+ const supersededBy: SupersededByState = {
50
+ resolved: false,
51
+ targetRef,
52
+ reason: 'federated-unavailable',
53
+ log: parsed.log,
54
+ id: parsed.id,
55
+ };
56
+ const derived: Finding = {
57
+ rule: 'superseded-target-federated-unavailable',
58
+ severity: 'info',
59
+ id: record.frontmatter.id,
60
+ message: `supersededBy target "${targetRef}" is a log-qualified ref; named-log federation is not available in this phase`,
61
+ };
62
+ return { entry: { ...summary, supersededBy }, derived };
63
+ }
64
+
65
+ const bucket = byId.get(parsed.id) ?? [];
66
+ if (bucket.length === 1) {
67
+ return { entry: { ...summary, supersededBy: { resolved: true, target: toSummary(bucket[0] as Adr) } }, derived: undefined };
68
+ }
69
+ if (bucket.length === 0) {
70
+ return { entry: { ...summary, supersededBy: { resolved: false, targetRef, reason: 'dangling' } }, derived: undefined };
71
+ }
72
+ const supersededBy: SupersededByState = { resolved: false, targetRef, reason: 'ambiguous', candidateCount: bucket.length };
73
+ const derived: Finding = {
74
+ rule: 'superseded-target-ambiguous',
75
+ severity: 'warn',
76
+ id: record.frontmatter.id,
77
+ message: `supersededBy target "${targetRef}" resolves to ${bucket.length} local records; see get_decision("${targetRef}") for the full candidate list`,
78
+ };
79
+ return { entry: { ...summary, supersededBy }, derived };
80
+ }
81
+
82
+ export function registerListSuperseded(server: McpServer, config: ToolConfig): void {
83
+ server.registerTool(
84
+ 'list_superseded',
85
+ {
86
+ title: 'List superseded decisions',
87
+ description:
88
+ 'List every superseded decision with its direct local replacement state: resolved, dangling, ambiguous (candidateCount only), or federated-unavailable. Direct edges only \u2014 no transitive lineage, no embedded candidate arrays.',
89
+ inputSchema: listSupersededInputSchema(),
90
+ outputSchema: listSupersededOutputSchema(),
91
+ annotations: ANNOTATIONS,
92
+ },
93
+ async (args) => {
94
+ const { cursor, limit, findingsCursor, findingsLimit } = args as ListSupersededArgs;
95
+
96
+ const loaded = await loadProjection(config);
97
+ if (!loaded.ok) {
98
+ return structuredResult(
99
+ loaded.outcome,
100
+ renderResponseText({ kind: 'corpus-unavailable', reason: loaded.outcome.reason }),
101
+ undefined,
102
+ );
103
+ }
104
+ const projection = loaded.projection;
105
+ const health = corpusHealthOf(projection);
106
+ const fp = projection.fingerprint;
107
+
108
+ const entries: SupersededEntry[] = [];
109
+ const derivedFindings: Finding[] = [];
110
+ for (const record of projection.records) {
111
+ if (record.frontmatter.status !== 'superseded') continue;
112
+ const resolved = resolveEntry(record, projection.byId);
113
+ entries.push(resolved.entry);
114
+ if (resolved.derived) derivedFindings.push(resolved.derived);
115
+ }
116
+
117
+ const responseFindings = sortFindingsCanonical([...projection.corpusFindings, ...derivedFindings]);
118
+
119
+ const primary = paginate({ items: entries, cursor, limit, scope: 'superseded.results', fp, qh: queryShapeHash([limit]) });
120
+ if (!primary.ok) return invalid(primary.reason);
121
+ const findingsPage = paginate({
122
+ items: responseFindings,
123
+ cursor: findingsCursor,
124
+ limit: findingsLimit,
125
+ scope: 'superseded.findings',
126
+ fp,
127
+ qh: queryShapeHash([findingsLimit]),
128
+ });
129
+ if (!findingsPage.ok) return invalid(findingsPage.reason);
130
+
131
+ const result: ListSupersededResult = {
132
+ outcome: 'entries',
133
+ items: primary.page.items,
134
+ cursor: primary.page.cursor,
135
+ findings: findingsPage.page,
136
+ };
137
+ return structuredResult(
138
+ result,
139
+ renderResponseText({ kind: 'entries', itemsLength: primary.page.items.length, findings: findingsPage.page.items.length }),
140
+ health,
141
+ );
142
+
143
+ function invalid(reason: Parameters<typeof invalidCursor>[0]) {
144
+ return structuredResult(invalidCursor(reason), renderResponseText({ kind: 'invalid-cursor', reason }), health);
145
+ }
146
+ },
147
+ );
148
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @adrkit/mcp — the `search_decisions` tool (US3, contracts/tools.md §5).
3
+ *
4
+ * Deterministic normalized literal substring search over id, title, tags, and
5
+ * body. Filters (status any-of, scope any-of, tags all-of, ANDed) apply before the
6
+ * normalizer. Graveyard records are included by default. Returns bounded summaries
7
+ * only — never a body, ranking score, or hidden index.
8
+ */
9
+
10
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
+ import type { Adr } from '@adrkit/core';
12
+ import { compareCodeUnits } from '../corpus/ordering.ts';
13
+ import { normalize } from '../search/normalize.ts';
14
+ import { paginate, queryShapeHash } from '../pagination/cursor.ts';
15
+ import {
16
+ ANNOTATIONS,
17
+ corpusHealthOf,
18
+ invalidCursor,
19
+ loadProjection,
20
+ renderResponseText,
21
+ searchDecisionsInputSchema,
22
+ searchDecisionsOutputSchema,
23
+ structuredResult,
24
+ toSummary,
25
+ type SearchDecisionsResult,
26
+ type SearchMatch,
27
+ type ToolConfig,
28
+ } from './shared.ts';
29
+
30
+ interface SearchDecisionsArgs {
31
+ readonly query: string;
32
+ readonly status?: string[];
33
+ readonly tags?: string[];
34
+ readonly scope?: string[];
35
+ readonly cursor?: string;
36
+ readonly limit: number;
37
+ readonly findingsCursor?: string;
38
+ readonly findingsLimit: number;
39
+ }
40
+
41
+ const MATCHED_FIELD_ORDER = ['body', 'id', 'tag', 'title'] as const;
42
+ type MatchedField = (typeof MATCHED_FIELD_ORDER)[number];
43
+
44
+ function sortedUnique(values: readonly string[] | undefined): string[] {
45
+ return values ? [...new Set(values)].sort(compareCodeUnits) : [];
46
+ }
47
+
48
+ function passesFilters(record: Adr, status: string[] | undefined, scope: string[] | undefined, tags: string[] | undefined): boolean {
49
+ if (status && !status.includes(record.frontmatter.status)) return false;
50
+ if (scope && !scope.includes(record.frontmatter.scope)) return false;
51
+ if (tags && !tags.every((tag) => record.frontmatter.tags.includes(tag))) return false;
52
+ return true;
53
+ }
54
+
55
+ function matchedFields(record: Adr, needle: string): MatchedField[] {
56
+ const fields: MatchedField[] = [];
57
+ if (normalize(record.body).includes(needle)) fields.push('body');
58
+ if (normalize(record.frontmatter.id).includes(needle)) fields.push('id');
59
+ if (record.frontmatter.tags.some((tag) => normalize(tag).includes(needle))) fields.push('tag');
60
+ if (normalize(record.frontmatter.title).includes(needle)) fields.push('title');
61
+ return fields; // already in the fixed ['body','id','tag','title'] order
62
+ }
63
+
64
+ export function registerSearchDecisions(server: McpServer, config: ToolConfig): void {
65
+ server.registerTool(
66
+ 'search_decisions',
67
+ {
68
+ title: 'Search decisions',
69
+ description:
70
+ 'Search the decision corpus (including the graveyard by default) by deterministic normalized literal substring over id, title, tags, and Markdown body. Optional status/scope (any-of) and tags (all-of) filters are ANDed. Returns bounded summaries only \u2014 no ranking, no model, no body.',
71
+ inputSchema: searchDecisionsInputSchema(),
72
+ outputSchema: searchDecisionsOutputSchema(),
73
+ annotations: ANNOTATIONS,
74
+ },
75
+ async (args) => {
76
+ const { query, status, tags, scope, cursor, limit, findingsCursor, findingsLimit } = args as SearchDecisionsArgs;
77
+
78
+ const loaded = await loadProjection(config);
79
+ if (!loaded.ok) {
80
+ return structuredResult(
81
+ loaded.outcome,
82
+ renderResponseText({ kind: 'corpus-unavailable', reason: loaded.outcome.reason }),
83
+ undefined,
84
+ );
85
+ }
86
+ const projection = loaded.projection;
87
+ const health = corpusHealthOf(projection);
88
+ const fp = projection.fingerprint;
89
+
90
+ const needle = normalize(query);
91
+ const primaryQh = queryShapeHash([needle, sortedUnique(status), sortedUnique(tags), sortedUnique(scope), limit]);
92
+ const findingsQh = queryShapeHash([findingsLimit]);
93
+
94
+ const matches: SearchMatch[] = [];
95
+ for (const record of projection.records) {
96
+ if (!passesFilters(record, status, scope, tags)) continue;
97
+ const fields = matchedFields(record, needle);
98
+ if (fields.length === 0) continue;
99
+ matches.push({ ...toSummary(record), matchedFields: fields });
100
+ }
101
+
102
+ const primary = paginate({ items: matches, cursor, limit, scope: 'search.results', fp, qh: primaryQh });
103
+ if (!primary.ok) return invalid(primary.reason);
104
+ const findingsPage = paginate({
105
+ items: projection.corpusFindings,
106
+ cursor: findingsCursor,
107
+ limit: findingsLimit,
108
+ scope: 'search.findings',
109
+ fp,
110
+ qh: findingsQh,
111
+ });
112
+ if (!findingsPage.ok) return invalid(findingsPage.reason);
113
+
114
+ const result: SearchDecisionsResult = {
115
+ outcome: 'results',
116
+ items: primary.page.items,
117
+ cursor: primary.page.cursor,
118
+ findings: findingsPage.page,
119
+ };
120
+ return structuredResult(
121
+ result,
122
+ renderResponseText({ kind: 'results', itemsLength: primary.page.items.length, findings: findingsPage.page.items.length }),
123
+ health,
124
+ );
125
+
126
+ function invalid(reason: Parameters<typeof invalidCursor>[0]) {
127
+ return structuredResult(invalidCursor(reason), renderResponseText({ kind: 'invalid-cursor', reason }), health);
128
+ }
129
+ },
130
+ );
131
+ }