@agentskit/doc-bridge 1.4.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/action.yml +1 -1
- package/dist/cli/program.js +2412 -268
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +74 -3
- package/dist/config/index.js.map +1 -1
- package/dist/{index-DhoAG9Ar.d.ts → index-Di7PkJuf.d.ts} +195 -8
- package/dist/index.d.ts +1942 -4
- package/dist/index.js +2143 -189
- package/dist/index.js.map +1 -1
- package/docs/PRD-doc-bridge-knowledge-engine.md +338 -0
- package/docs/knowledge-engine-runbook.md +44 -0
- package/ecosystem-claims.json +16 -16
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +84 -127
- package/mcpb/manifest.json +25 -1
- package/package.json +2 -2
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +97 -0
- package/src/cli/program.ts +285 -3
- package/src/config/defaults.ts +14 -1
- package/src/config/schema.ts +67 -0
- package/src/discovery/documentation.ts +320 -0
- package/src/discovery/repository.ts +514 -0
- package/src/fixes/proposals.ts +165 -0
- package/src/index-builder/content-hash.ts +9 -2
- package/src/index.ts +99 -2
- package/src/mcp/server.ts +178 -8
- package/src/reconciliation/reconcile.ts +227 -0
- package/src/report/html.ts +74 -0
- package/src/rules/engine.ts +180 -0
- package/src/safety/repository.ts +84 -0
- package/src/schemas/knowledge.ts +315 -0
- package/src/validate.ts +24 -0
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +238 -0
- package/tsup.config.ts +2 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
5
|
+
import { FixProposalV1Schema, type FixProposalV1 } from '../schemas/knowledge.js'
|
|
6
|
+
import { containedPath } from '../safety/repository.js'
|
|
7
|
+
|
|
8
|
+
export type FixProposalOptions = {
|
|
9
|
+
readonly baseRevision: string
|
|
10
|
+
readonly configurationHash: string
|
|
11
|
+
readonly projectName?: string
|
|
12
|
+
readonly toolVersion?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type FixApplyOptions = {
|
|
16
|
+
readonly currentRevision?: string
|
|
17
|
+
readonly verify?: (changedPaths: readonly string[]) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type FixChange = { readonly path: string; readonly before: string; readonly after: string }
|
|
21
|
+
|
|
22
|
+
const hash = (value: unknown): string => sha256NormalizedV1(value)
|
|
23
|
+
|
|
24
|
+
const artifactMetadata = (root: string, options: FixProposalOptions) => ({
|
|
25
|
+
schemaVersion: 1 as const,
|
|
26
|
+
contentHash: '0'.repeat(64),
|
|
27
|
+
contentHashAlgo: 'sha256-normalized-v1' as const,
|
|
28
|
+
project: { name: options.projectName ?? basename(resolve(root)), root: '.' },
|
|
29
|
+
sourceRevision: options.baseRevision,
|
|
30
|
+
sourceRevisionKind: options.baseRevision.length === 40 ? 'git' as const : 'content' as const,
|
|
31
|
+
configurationHash: options.configurationHash,
|
|
32
|
+
pipelineVersion: '1.0.0',
|
|
33
|
+
analyzerVersions: { fixes: options.toolVersion ?? '1.0.0' },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const unifiedDiff = (changes: readonly FixChange[]): string => changes.map((change) => {
|
|
37
|
+
const before = change.before.split('\n').map((line) => `-${line}`).join('\n')
|
|
38
|
+
const after = change.after.split('\n').map((line) => `+${line}`).join('\n')
|
|
39
|
+
return `--- a/${change.path}\n+++ b/${change.path}\n@@\n${before}\n${after}`
|
|
40
|
+
}).join('\n')
|
|
41
|
+
|
|
42
|
+
const makeProposal = (root: string, options: FixProposalOptions, changes: readonly FixChange[], preconditions: readonly string[], postconditions: readonly string[]): FixProposalV1 => {
|
|
43
|
+
const draft = {
|
|
44
|
+
...artifactMetadata(root, options),
|
|
45
|
+
type: 'fix-proposal' as const,
|
|
46
|
+
proposalId: `fix-${hash(changes).slice(0, 20)}`,
|
|
47
|
+
baseRevision: options.baseRevision,
|
|
48
|
+
affectedFiles: changes.map(({ path, before }) => ({ path, contentHash: sha256NormalizedV1(before) })),
|
|
49
|
+
changes,
|
|
50
|
+
preconditions,
|
|
51
|
+
diff: unifiedDiff(changes),
|
|
52
|
+
postconditions,
|
|
53
|
+
status: 'proposed' as const,
|
|
54
|
+
}
|
|
55
|
+
return FixProposalV1Schema.parse({ ...draft, contentHash: contentHashForArtifactV1(draft) })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const walkMarkdown = (root: string, directory = root): string[] => readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
59
|
+
if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') return []
|
|
60
|
+
const path = join(directory, entry.name)
|
|
61
|
+
if (entry.isDirectory()) return walkMarkdown(root, path)
|
|
62
|
+
return entry.isFile() && ['.md', '.mdx'].includes(extname(entry.name).toLowerCase()) ? [relative(root, path).split(sep).join('/')] : []
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const localLink = /(!?)\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/g
|
|
66
|
+
|
|
67
|
+
const sortJson = (value: unknown): unknown => Array.isArray(value)
|
|
68
|
+
? value.map(sortJson)
|
|
69
|
+
: value && typeof value === 'object'
|
|
70
|
+
? Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sortJson(item)]))
|
|
71
|
+
: value
|
|
72
|
+
|
|
73
|
+
export const createMarkdownLinkFixProposal = (root: string, options: FixProposalOptions): FixProposalV1 | undefined => {
|
|
74
|
+
const projectRoot = realpathSync.native(resolve(root))
|
|
75
|
+
const paths = walkMarkdown(projectRoot)
|
|
76
|
+
const changes: FixChange[] = []
|
|
77
|
+
for (const path of paths) {
|
|
78
|
+
const content = readFileSync(join(projectRoot, path), 'utf8')
|
|
79
|
+
let next = content
|
|
80
|
+
for (const match of content.matchAll(localLink)) {
|
|
81
|
+
const target = match[3]
|
|
82
|
+
if (!target || match[1] === '!' || /^(?:[a-z]+:|\/|#)/i.test(target)) continue
|
|
83
|
+
const targetPath = target.split('#')[0]?.split('?')[0]
|
|
84
|
+
if (!targetPath || existsSync(resolve(projectRoot, dirname(path), targetPath))) continue
|
|
85
|
+
const targetStem = basename(targetPath, extname(targetPath)).toLowerCase()
|
|
86
|
+
const labelStem = (match[2] ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '')
|
|
87
|
+
const candidates = paths.filter((candidate) => basename(candidate, extname(candidate)).toLowerCase() === targetStem || basename(candidate, extname(candidate)).toLowerCase().replace(/[^a-z0-9]+/g, '') === labelStem)
|
|
88
|
+
if (candidates.length !== 1) continue
|
|
89
|
+
let replacement = relative(dirname(path), candidates[0]!).split(sep).join('/')
|
|
90
|
+
if (target.startsWith('./') && !replacement.startsWith('.')) replacement = `./${replacement}`
|
|
91
|
+
next = next.replace(match[0], match[0].replace(target, replacement))
|
|
92
|
+
}
|
|
93
|
+
if (next !== content) changes.push({ path, before: content, after: next })
|
|
94
|
+
}
|
|
95
|
+
return changes.length ? makeProposal(projectRoot, options, changes, ['Each replacement has exactly one Markdown target.'], ['All corrected local Markdown links resolve.']) : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const createArtifactNormalizationProposal = (root: string, artifactPath: string, options: FixProposalOptions): FixProposalV1 | undefined => {
|
|
99
|
+
const projectRoot = realpathSync.native(resolve(root))
|
|
100
|
+
const path = artifactPath.split(sep).join('/')
|
|
101
|
+
const absolute = containedPath(projectRoot, path)
|
|
102
|
+
if (!absolute || !existsSync(absolute) || !statSync(absolute).isFile()) return undefined
|
|
103
|
+
const before = readFileSync(absolute, 'utf8')
|
|
104
|
+
let after: string
|
|
105
|
+
try { after = `${JSON.stringify(sortJson(JSON.parse(before) as unknown), null, 2)}\n` } catch { return undefined }
|
|
106
|
+
return after === before ? undefined : makeProposal(projectRoot, options, [{ path: relative(projectRoot, absolute).split(sep).join('/'), before, after }], ['The artifact contains valid JSON.'], ['The artifact is valid canonical JSON with one trailing newline.'])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const approveFixProposal = (proposalInput: unknown, approvedBy: string, approvedAt = new Date().toISOString()): FixProposalV1 => {
|
|
110
|
+
const proposal = FixProposalV1Schema.parse(proposalInput)
|
|
111
|
+
if (proposal.status !== 'proposed') throw new Error(`Only proposed fixes can be approved; received "${proposal.status}".`)
|
|
112
|
+
const approved = { ...proposal, status: 'approved' as const, approval: { proposalHash: proposal.contentHash, approvedAt, approvedBy }, contentHash: '0'.repeat(64) }
|
|
113
|
+
return FixProposalV1Schema.parse({ ...approved, contentHash: contentHashForArtifactV1(approved) })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const bindingHash = (proposal: FixProposalV1): string => {
|
|
117
|
+
const { approval: _approval, contentHash: _contentHash, status: _status, ...rest } = proposal
|
|
118
|
+
return contentHashForArtifactV1({ ...rest, contentHash: '0'.repeat(64), status: 'proposed' as const })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const applyFixProposal = (root: string, proposalInput: unknown, options: FixApplyOptions = {}): FixProposalV1 => {
|
|
122
|
+
const proposal = FixProposalV1Schema.parse(proposalInput)
|
|
123
|
+
if (proposal.status !== 'approved' || !proposal.approval) throw new Error('Only an explicitly approved fix proposal can be applied.')
|
|
124
|
+
if (proposal.approval.proposalHash !== bindingHash(proposal)) throw new Error('Approval is not bound to the exact proposal content.')
|
|
125
|
+
if (options.currentRevision && options.currentRevision !== proposal.baseRevision) throw new Error('The repository revision changed since this proposal was created.')
|
|
126
|
+
if (!proposal.changes?.length) throw new Error('This proposal has no executable changes.')
|
|
127
|
+
|
|
128
|
+
const projectRoot = realpathSync.native(resolve(root))
|
|
129
|
+
const originals = new Map<string, string>()
|
|
130
|
+
const affected = new Map(proposal.affectedFiles.map((file) => [file.path, file]))
|
|
131
|
+
if (proposal.changes.some((change) => !affected.has(change.path) || affected.get(change.path)?.contentHash !== sha256NormalizedV1(change.before)) || affected.size !== proposal.changes.length) {
|
|
132
|
+
throw new Error('Proposal changes do not match its affected-file hashes.')
|
|
133
|
+
}
|
|
134
|
+
for (const file of proposal.affectedFiles) {
|
|
135
|
+
const absolute = containedPath(projectRoot, file.path)
|
|
136
|
+
if (!absolute || !existsSync(absolute)) throw new Error(`Affected file is unavailable or escapes the repository root: ${file.path}`)
|
|
137
|
+
const current = readFileSync(absolute, 'utf8')
|
|
138
|
+
if (sha256NormalizedV1(current) !== file.contentHash) throw new Error(`Affected file changed since proposal creation: ${file.path}`)
|
|
139
|
+
originals.set(absolute, current)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
for (const change of proposal.changes) {
|
|
144
|
+
const absolute = resolve(projectRoot, change.path)
|
|
145
|
+
writeFileSync(`${absolute}.docbridge-${process.pid}.tmp`, change.after, 'utf8')
|
|
146
|
+
}
|
|
147
|
+
for (const change of proposal.changes) {
|
|
148
|
+
const absolute = resolve(projectRoot, change.path)
|
|
149
|
+
renameSync(`${absolute}.docbridge-${process.pid}.tmp`, absolute)
|
|
150
|
+
}
|
|
151
|
+
for (const change of proposal.changes) {
|
|
152
|
+
if (readFileSync(resolve(projectRoot, change.path), 'utf8') !== change.after) throw new Error(`Postcondition failed for ${change.path}`)
|
|
153
|
+
}
|
|
154
|
+
options.verify?.(proposal.changes.map((change) => change.path))
|
|
155
|
+
} catch (error) {
|
|
156
|
+
for (const [absolute, content] of originals) writeFileSync(absolute, content, 'utf8')
|
|
157
|
+
for (const change of proposal.changes) {
|
|
158
|
+
const temp = `${resolve(projectRoot, change.path)}.docbridge-${process.pid}.tmp`
|
|
159
|
+
if (existsSync(temp)) unlinkSync(temp)
|
|
160
|
+
}
|
|
161
|
+
throw error
|
|
162
|
+
}
|
|
163
|
+
const applied = { ...proposal, status: 'applied' as const, contentHash: '0'.repeat(64) }
|
|
164
|
+
return FixProposalV1Schema.parse({ ...applied, contentHash: contentHashForArtifactV1(applied) })
|
|
165
|
+
}
|
|
@@ -13,7 +13,14 @@ const sortValue = (value: unknown): unknown => {
|
|
|
13
13
|
return value
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export const canonicalJsonV1 = (payload: unknown): string => JSON.stringify(sortValue(payload))
|
|
17
|
+
|
|
16
18
|
export const sha256NormalizedV1 = (payload: unknown): string => {
|
|
17
|
-
const normalized =
|
|
19
|
+
const normalized = canonicalJsonV1(payload)
|
|
18
20
|
return createHash('sha256').update(normalized, 'utf8').digest('hex')
|
|
19
|
-
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const contentHashForArtifactV1 = <T extends { readonly contentHash: string }>(artifact: T): string => {
|
|
24
|
+
const { contentHash: _contentHash, ...payload } = artifact
|
|
25
|
+
return sha256NormalizedV1(payload)
|
|
26
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,16 @@ export {
|
|
|
15
15
|
type DocBridgeConfigV1,
|
|
16
16
|
type AgentCorpusConfig,
|
|
17
17
|
type DocumentationStandardV1Config,
|
|
18
|
+
RuleIdSchema,
|
|
19
|
+
RuleSeveritySchema,
|
|
20
|
+
RulesConfigSchema,
|
|
21
|
+
WorkflowConfigSchema,
|
|
22
|
+
RepositorySafetyConfigSchema,
|
|
23
|
+
type RuleId,
|
|
24
|
+
type RuleSeverity,
|
|
25
|
+
type RulesConfig,
|
|
26
|
+
type WorkflowConfig,
|
|
27
|
+
type RepositorySafetyConfig,
|
|
18
28
|
} from './config/schema.js'
|
|
19
29
|
|
|
20
30
|
export {
|
|
@@ -55,16 +65,63 @@ export {
|
|
|
55
65
|
|
|
56
66
|
export {
|
|
57
67
|
parseAgentHandoff,
|
|
68
|
+
parseAgentProposal,
|
|
58
69
|
parseAgentSearch,
|
|
59
70
|
parseDocBridgeConfig,
|
|
60
71
|
parseDocBridgeIndex,
|
|
72
|
+
parseDiscoverySnapshot,
|
|
73
|
+
parseFixProposal,
|
|
61
74
|
parseMemoryCandidate,
|
|
75
|
+
parseReconciliationReport,
|
|
76
|
+
parseWorkflowRun,
|
|
62
77
|
safeParseAgentHandoff,
|
|
63
78
|
type ParseIssue,
|
|
64
79
|
type ParseResult,
|
|
65
80
|
} from './validate.js'
|
|
66
81
|
|
|
67
82
|
export { buildDocBridgeIndex, type BuildIndexOptions, type BuildIndexResult } from './index-builder/build-index.js'
|
|
83
|
+
export { discoverRepository, type DiscoveryOptions } from './discovery/repository.js'
|
|
84
|
+
export { containedPath, DEFAULT_SAFETY_EXCLUDES, redactSecrets, redactValue, safeWalkFiles, type SafeWalkOptions, type SafeWalkResult } from './safety/repository.js'
|
|
85
|
+
export { DEFAULT_REGISTRY_AGENT_ID, createRegistryAgentAdapter, loadRegistryAgentMetadata, loadRegistryAgentRunner, persistRegistryAgentProposal, type RegistryAgentAdapter, type RegistryAgentContext, type RegistryAgentMetadata, type RegistryAgentRunner } from './agents/registry-adapter.js'
|
|
86
|
+
export {
|
|
87
|
+
applyDocumentationDeclarations,
|
|
88
|
+
parseDocumentationDeclarations,
|
|
89
|
+
type DocumentationAnalysisResult,
|
|
90
|
+
type DocumentationDeclarationInput,
|
|
91
|
+
type DocumentationDeclarationOptions,
|
|
92
|
+
type DocumentationDeclarationResult,
|
|
93
|
+
type DocumentationDiagnostic,
|
|
94
|
+
} from './discovery/documentation.js'
|
|
95
|
+
export { reconcileKnowledge } from './reconciliation/reconcile.js'
|
|
96
|
+
export { renderOfflineReport, type OfflineReportInput, type OfflineReportOptions } from './report/html.js'
|
|
97
|
+
export {
|
|
98
|
+
applyFixProposal,
|
|
99
|
+
approveFixProposal,
|
|
100
|
+
createArtifactNormalizationProposal,
|
|
101
|
+
createMarkdownLinkFixProposal,
|
|
102
|
+
type FixApplyOptions,
|
|
103
|
+
type FixProposalOptions,
|
|
104
|
+
} from './fixes/proposals.js'
|
|
105
|
+
export {
|
|
106
|
+
WORKFLOW_STAGES,
|
|
107
|
+
loadWorkflowManifest,
|
|
108
|
+
loadWorkflowStepOutput,
|
|
109
|
+
runWorkflow,
|
|
110
|
+
type WorkflowExecutionResult,
|
|
111
|
+
type WorkflowOptions,
|
|
112
|
+
type WorkflowStage,
|
|
113
|
+
type WorkflowStageContext,
|
|
114
|
+
type WorkflowStageHandler,
|
|
115
|
+
} from './workflow/engine.js'
|
|
116
|
+
export {
|
|
117
|
+
evaluateRules,
|
|
118
|
+
parseRuleId,
|
|
119
|
+
parseRuleSeverity,
|
|
120
|
+
type RuleEngineOptions,
|
|
121
|
+
type RuleEvaluationResult,
|
|
122
|
+
type RuleFinding,
|
|
123
|
+
type RuleMode,
|
|
124
|
+
} from './rules/engine.js'
|
|
68
125
|
export {
|
|
69
126
|
formatEcosystemLlmsBlock,
|
|
70
127
|
formatEcosystemLlmsSection,
|
|
@@ -98,7 +155,7 @@ export {
|
|
|
98
155
|
type DocumentationStandardRuleResult,
|
|
99
156
|
type DocumentationStandardRuleStatus,
|
|
100
157
|
} from './conformance/documentation-standard-v1.js'
|
|
101
|
-
export { MCP_TOOLS, handleMcpRequest, startMcpStdioServer } from './mcp/server.js'
|
|
158
|
+
export { MCP_TOOLS, handleMcpRequest, respondMcpRequest, startMcpStdioServer } from './mcp/server.js'
|
|
102
159
|
export { installMcpConfig, mcpSnippet, type McpInstallResult, type McpInstallTarget } from './mcp/install.js'
|
|
103
160
|
export { runDoctor, formatDoctorText, type DoctorReport, type DoctorIssue, type DoctorCoverage } from './doctor/run-doctor.js'
|
|
104
161
|
export {
|
|
@@ -115,7 +172,47 @@ export {
|
|
|
115
172
|
type GithubPrOptions,
|
|
116
173
|
type GithubPrResult,
|
|
117
174
|
} from './memory/github-pr.js'
|
|
118
|
-
export { sha256NormalizedV1 } from './index-builder/content-hash.js'
|
|
175
|
+
export { canonicalJsonV1, contentHashForArtifactV1, sha256NormalizedV1 } from './index-builder/content-hash.js'
|
|
176
|
+
export {
|
|
177
|
+
AgentProposalV1Schema,
|
|
178
|
+
AffectedFileSchema,
|
|
179
|
+
CoverageSchema,
|
|
180
|
+
DiagnosticSeveritySchema,
|
|
181
|
+
DiscoverySnapshotV1Schema,
|
|
182
|
+
EntitySchema,
|
|
183
|
+
EvidenceSchema,
|
|
184
|
+
EvidenceSourceSchema,
|
|
185
|
+
FindingStatusSchema,
|
|
186
|
+
FixProposalStatusSchema,
|
|
187
|
+
FixChangeSchema,
|
|
188
|
+
FixProposalV1Schema,
|
|
189
|
+
KNOWLEDGE_CONTENT_HASH_ALGO,
|
|
190
|
+
KNOWLEDGE_SCHEMA_VERSION,
|
|
191
|
+
ProvenanceSchema,
|
|
192
|
+
ProjectIdentitySchema,
|
|
193
|
+
ProposalOriginSchema,
|
|
194
|
+
ReconciliationReportV1Schema,
|
|
195
|
+
RelationSchema,
|
|
196
|
+
WorkflowRunV1Schema,
|
|
197
|
+
WorkflowStepSchema,
|
|
198
|
+
WorkflowStateSchema,
|
|
199
|
+
WorkflowTransitionSchema,
|
|
200
|
+
type AgentProposalV1,
|
|
201
|
+
type DiagnosticSeverity,
|
|
202
|
+
type DiscoverySnapshotV1,
|
|
203
|
+
type Evidence,
|
|
204
|
+
type FindingStatus,
|
|
205
|
+
type FixProposalV1,
|
|
206
|
+
type FixChange,
|
|
207
|
+
type KnowledgeArtifactV1,
|
|
208
|
+
type KnowledgeDiagnostic,
|
|
209
|
+
type KnowledgeEntity,
|
|
210
|
+
type KnowledgeRelation,
|
|
211
|
+
type Provenance,
|
|
212
|
+
type ReconciliationReportV1,
|
|
213
|
+
type WorkflowRunV1,
|
|
214
|
+
type WorkflowState,
|
|
215
|
+
} from './schemas/knowledge.js'
|
|
119
216
|
export { IndexNotFoundError, indexFilePath, loadDocBridgeIndex, resolveRoot } from './query/load-index.js'
|
|
120
217
|
export { runQuery, type QueryKind, type QueryRequest, type QueryResult } from './query/query.js'
|
|
121
218
|
export { searchIndex, type SearchMatch } from './query/search.js'
|
package/src/mcp/server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFileSync, realpathSync } from 'node:fs'
|
|
2
|
-
import { relative, resolve } from 'node:path'
|
|
1
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join, relative, resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { z, ZodError } from 'zod'
|
|
5
5
|
|
|
@@ -13,6 +13,14 @@ import { runQuery } from '../query/query.js'
|
|
|
13
13
|
import { searchIndex } from '../query/search.js'
|
|
14
14
|
import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
|
|
15
15
|
import { PACKAGE_VERSION } from '../version.js'
|
|
16
|
+
import { loadWorkflowManifest, loadWorkflowStepOutput } from '../workflow/engine.js'
|
|
17
|
+
import { parseDiscoverySnapshot, parseReconciliationReport } from '../validate.js'
|
|
18
|
+
import { applyFixProposal, approveFixProposal, createArtifactNormalizationProposal, createMarkdownLinkFixProposal } from '../fixes/proposals.js'
|
|
19
|
+
import { createRegistryAgentAdapter, loadRegistryAgentRunner, persistRegistryAgentProposal } from '../agents/registry-adapter.js'
|
|
20
|
+
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
21
|
+
import { discoverRepository } from '../discovery/repository.js'
|
|
22
|
+
import { FixProposalV1Schema, type DiscoverySnapshotV1, type ReconciliationReportV1, type FixProposalV1 } from '../schemas/knowledge.js'
|
|
23
|
+
import { redactValue } from '../safety/repository.js'
|
|
16
24
|
|
|
17
25
|
type JsonRpcRequest = {
|
|
18
26
|
readonly jsonrpc?: '2.0'
|
|
@@ -99,6 +107,47 @@ export const MCP_TOOLS = [
|
|
|
99
107
|
annotations: { readOnlyHint: true },
|
|
100
108
|
inputSchema: { type: 'object', properties: {} },
|
|
101
109
|
},
|
|
110
|
+
{
|
|
111
|
+
name: 'docbridge.snapshot',
|
|
112
|
+
title: 'Read the latest discovery snapshot',
|
|
113
|
+
description: 'Read the bounded canonical repository snapshot from the latest workflow run.',
|
|
114
|
+
annotations: { readOnlyHint: true },
|
|
115
|
+
inputSchema: { type: 'object', properties: { runId: { type: 'string' } } },
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'docbridge.report',
|
|
119
|
+
title: 'Read the latest reconciliation report',
|
|
120
|
+
description: 'Read the canonical reconciliation report from the latest workflow run.',
|
|
121
|
+
annotations: { readOnlyHint: true },
|
|
122
|
+
inputSchema: { type: 'object', properties: { runId: { type: 'string' } } },
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'docbridge.diagnostics',
|
|
126
|
+
title: 'Read reconciliation diagnostics',
|
|
127
|
+
description: 'Read bounded diagnostics from the latest canonical reconciliation report.',
|
|
128
|
+
annotations: { readOnlyHint: true },
|
|
129
|
+
inputSchema: { type: 'object', properties: { status: { type: 'string' }, severity: { type: 'string' } } },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'docbridge.relations',
|
|
133
|
+
title: 'Read architecture relations',
|
|
134
|
+
description: 'Read bounded observed and declared relations from the latest canonical snapshot.',
|
|
135
|
+
annotations: { readOnlyHint: true },
|
|
136
|
+
inputSchema: { type: 'object', properties: { kind: { type: 'string' }, limit: { type: 'number' } } },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'docbridge.run',
|
|
140
|
+
title: 'Read workflow state',
|
|
141
|
+
description: 'Read the latest resumable workflow state and artifact references.',
|
|
142
|
+
annotations: { readOnlyHint: true },
|
|
143
|
+
inputSchema: { type: 'object', properties: {} },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'docbridge.proposals',
|
|
147
|
+
title: 'Read or approve proposals',
|
|
148
|
+
description: 'Create, inspect, approve and apply deterministic proposals through the shared human-gated workflow.',
|
|
149
|
+
inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'propose-links', 'propose-normalize', 'suggest', 'approve', 'apply'] }, proposalHash: { type: 'string' }, artifactPath: { type: 'string' }, approvedBy: { type: 'string' }, proposal: { type: 'object' } } },
|
|
150
|
+
},
|
|
102
151
|
] as const
|
|
103
152
|
|
|
104
153
|
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
@@ -126,6 +175,11 @@ const DocGetArgsSchema = z
|
|
|
126
175
|
})
|
|
127
176
|
.refine((args) => args.id || args.path, 'doc.get requires id or path')
|
|
128
177
|
|
|
178
|
+
const WorkflowRunArgsSchema = z.object({ runId: z.string().min(1).optional() })
|
|
179
|
+
const DiagnosticsArgsSchema = z.object({ status: z.string().min(1).optional(), severity: z.string().min(1).optional() })
|
|
180
|
+
const RelationsArgsSchema = z.object({ kind: z.string().min(1).optional(), limit: z.number().int().positive().max(500).optional() })
|
|
181
|
+
const ProposalsArgsSchema = z.object({ action: z.enum(['list', 'propose-links', 'propose-normalize', 'suggest', 'approve', 'apply']).optional(), proposalHash: z.string().min(1).optional(), artifactPath: z.string().min(1).optional(), approvedBy: z.string().min(1).optional(), proposal: z.unknown().optional() })
|
|
182
|
+
|
|
129
183
|
const parseToolArgs = <T>(tool: string, schema: z.ZodType<T>, value: unknown): T => {
|
|
130
184
|
try {
|
|
131
185
|
return schema.parse(value)
|
|
@@ -169,6 +223,45 @@ const resolveDocPath = (root: string, relPath: string): string => {
|
|
|
169
223
|
return abs
|
|
170
224
|
}
|
|
171
225
|
|
|
226
|
+
const workflowStateDir = (ctx: McpContext): string => resolve(ctx.root, ctx.config.workflow?.stateDir ?? '.doc-bridge/workflow')
|
|
227
|
+
|
|
228
|
+
const workflowRun = (ctx: McpContext) => loadWorkflowManifest(workflowStateDir(ctx))
|
|
229
|
+
|
|
230
|
+
const ensureLatestRun = (ctx: McpContext, runId?: string) => {
|
|
231
|
+
const run = workflowRun(ctx)
|
|
232
|
+
if (runId && run.runId !== runId) throw new Error(`Unknown workflow run "${runId}"`)
|
|
233
|
+
if (run.state === 'stale' || run.state === 'failed') throw new Error(`Workflow run is ${run.state}; resume or create a valid run before reading artifacts.`)
|
|
234
|
+
return run
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const workflowSnapshot = (ctx: McpContext, runId?: string): DiscoverySnapshotV1 => {
|
|
238
|
+
ensureLatestRun(ctx, runId)
|
|
239
|
+
return parseDiscoverySnapshot(loadWorkflowStepOutput(workflowStateDir(ctx), 'normalize'))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const workflowReport = (ctx: McpContext, runId?: string): ReconciliationReportV1 => {
|
|
243
|
+
ensureLatestRun(ctx, runId)
|
|
244
|
+
return parseReconciliationReport(loadWorkflowStepOutput(workflowStateDir(ctx), 'reconcile'))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const proposalPath = (ctx: McpContext): string => join(ctx.root, '.doc-bridge', 'proposal.json')
|
|
248
|
+
const readSavedProposal = (ctx: McpContext, input: unknown): FixProposalV1 => {
|
|
249
|
+
if (input !== undefined) return FixProposalV1Schema.parse(input)
|
|
250
|
+
try { return FixProposalV1Schema.parse(JSON.parse(readFileSync(proposalPath(ctx), 'utf8')) as unknown) } catch { throw new Error(`No saved fix proposal at ${proposalPath(ctx)}.`) }
|
|
251
|
+
}
|
|
252
|
+
const saveProposal = (ctx: McpContext, proposal: FixProposalV1): void => { mkdirSync(join(ctx.root, '.doc-bridge'), { recursive: true }); writeFileSync(proposalPath(ctx), `${JSON.stringify(proposal, null, 2)}\n`, 'utf8') }
|
|
253
|
+
|
|
254
|
+
const enabledMcpTools = (ctx: McpContext) => {
|
|
255
|
+
const configured = ctx.config.surfaces?.mcp?.tools
|
|
256
|
+
if (!configured || configured.length === MCP_TOOLS.length && MCP_TOOLS.every((tool) => configured.includes(tool.name as typeof configured[number]))) return MCP_TOOLS
|
|
257
|
+
return MCP_TOOLS.filter((tool) => configured.includes(tool.name as typeof configured[number]))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const assertMcpToolEnabled = (ctx: McpContext, name: string): void => {
|
|
261
|
+
if (!MCP_TOOLS.some((tool) => tool.name === name)) throw new Error(`Unknown tool "${name}"`)
|
|
262
|
+
if (!enabledMcpTools(ctx).some((tool) => tool.name === name)) throw new Error(`MCP tool "${name}" is disabled by configuration.`)
|
|
263
|
+
}
|
|
264
|
+
|
|
172
265
|
export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unknown => {
|
|
173
266
|
if (request.method === 'initialize') {
|
|
174
267
|
return {
|
|
@@ -178,12 +271,14 @@ export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unkn
|
|
|
178
271
|
}
|
|
179
272
|
}
|
|
180
273
|
|
|
181
|
-
if (request.method === 'tools/list') return { tools:
|
|
274
|
+
if (request.method === 'tools/list') return { tools: enabledMcpTools(ctx) }
|
|
182
275
|
|
|
183
276
|
if (request.method === 'tools/call') {
|
|
184
277
|
const params = asRecord(request.params)
|
|
185
278
|
const name = params.name
|
|
186
279
|
const args = asRecord(params.arguments)
|
|
280
|
+
if (typeof name !== 'string') throw new Error('MCP tools/call requires a tool name.')
|
|
281
|
+
assertMcpToolEnabled(ctx, name)
|
|
187
282
|
const index = () => ctx.loadIndex?.() ?? loadDocBridgeIndex(ctx.root, ctx.config)
|
|
188
283
|
|
|
189
284
|
if (name === 'handoff.resolve') {
|
|
@@ -232,6 +327,77 @@ export const handleMcpRequest = (ctx: McpContext, request: JsonRpcRequest): unkn
|
|
|
232
327
|
})
|
|
233
328
|
}
|
|
234
329
|
|
|
330
|
+
if (name === 'docbridge.snapshot') {
|
|
331
|
+
const parsed = parseToolArgs('docbridge.snapshot', WorkflowRunArgsSchema, args)
|
|
332
|
+
return textResult(redactValue(workflowSnapshot(ctx, parsed.runId)))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (name === 'docbridge.report') {
|
|
336
|
+
const parsed = parseToolArgs('docbridge.report', WorkflowRunArgsSchema, args)
|
|
337
|
+
return textResult(redactValue(workflowReport(ctx, parsed.runId)))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (name === 'docbridge.diagnostics') {
|
|
341
|
+
const parsed = parseToolArgs('docbridge.diagnostics', DiagnosticsArgsSchema, args)
|
|
342
|
+
const diagnostics = workflowReport(ctx).diagnostics.filter((diagnostic) =>
|
|
343
|
+
(!parsed.status || diagnostic.status === parsed.status) && (!parsed.severity || diagnostic.severity === parsed.severity),
|
|
344
|
+
)
|
|
345
|
+
return textResult(redactValue({ reportHash: workflowReport(ctx).contentHash, diagnostics }))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (name === 'docbridge.relations') {
|
|
349
|
+
const parsed = parseToolArgs('docbridge.relations', RelationsArgsSchema, args)
|
|
350
|
+
const snapshot = workflowSnapshot(ctx)
|
|
351
|
+
return textResult({ snapshotHash: snapshot.contentHash, relations: snapshot.relations.filter((relation) => !parsed.kind || relation.kind === parsed.kind).slice(0, parsed.limit ?? 100) })
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (name === 'docbridge.run') {
|
|
355
|
+
parseToolArgs('docbridge.run', z.object({}), args)
|
|
356
|
+
return textResult(workflowRun(ctx))
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (name === 'docbridge.proposals') {
|
|
360
|
+
const parsed = parseToolArgs('docbridge.proposals', ProposalsArgsSchema, args)
|
|
361
|
+
const run = (() => { try { return workflowRun(ctx) } catch { return undefined } })()
|
|
362
|
+
if (!parsed.action || parsed.action === 'list') {
|
|
363
|
+
let proposal: FixProposalV1 | undefined
|
|
364
|
+
try { proposal = readSavedProposal(ctx, undefined) } catch { proposal = undefined }
|
|
365
|
+
return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposals: proposal ? [proposal] : [] }))
|
|
366
|
+
}
|
|
367
|
+
if (parsed.action === 'suggest') {
|
|
368
|
+
return loadRegistryAgentRunner(ctx.root, ctx.config).then(async (runner) => {
|
|
369
|
+
const snapshot = workflowSnapshot(ctx)
|
|
370
|
+
const report = workflowReport(ctx)
|
|
371
|
+
const adapter = createRegistryAgentAdapter(ctx.root, ctx.config, runner)
|
|
372
|
+
const proposal = await adapter.run(snapshot, report)
|
|
373
|
+
const savedPath = persistRegistryAgentProposal(workflowStateDir(ctx), proposal)
|
|
374
|
+
return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal, proposalPath: savedPath }))
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
const discovered = discoverRepository({ root: ctx.root, config: ctx.config })
|
|
378
|
+
const options = { baseRevision: discovered.sourceRevision, configurationHash: sha256NormalizedV1(ctx.config), ...(ctx.config.project?.name ? { projectName: ctx.config.project.name } : {}) }
|
|
379
|
+
if (parsed.action === 'propose-links') {
|
|
380
|
+
const proposal = createMarkdownLinkFixProposal(ctx.root, options)
|
|
381
|
+
if (proposal) saveProposal(ctx, proposal)
|
|
382
|
+
return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal: proposal ?? null }))
|
|
383
|
+
}
|
|
384
|
+
if (parsed.action === 'propose-normalize') {
|
|
385
|
+
if (!parsed.artifactPath) throw new Error('docbridge.proposals propose-normalize requires artifactPath')
|
|
386
|
+
const proposal = createArtifactNormalizationProposal(ctx.root, parsed.artifactPath, options)
|
|
387
|
+
if (proposal) saveProposal(ctx, proposal)
|
|
388
|
+
return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal: proposal ?? null }))
|
|
389
|
+
}
|
|
390
|
+
if (parsed.action === 'approve') {
|
|
391
|
+
const proposal = approveFixProposal(readSavedProposal(ctx, parsed.proposal), parsed.approvedBy ?? 'human')
|
|
392
|
+
if (parsed.proposalHash && proposal.approval?.proposalHash !== parsed.proposalHash) throw new Error('proposalHash does not match the saved proposal')
|
|
393
|
+
saveProposal(ctx, proposal)
|
|
394
|
+
return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal }))
|
|
395
|
+
}
|
|
396
|
+
const proposal = applyFixProposal(ctx.root, readSavedProposal(ctx, parsed.proposal), { currentRevision: discovered.sourceRevision })
|
|
397
|
+
saveProposal(ctx, proposal)
|
|
398
|
+
return textResult(redactValue({ ...(run ? { runId: run.runId } : {}), proposal }))
|
|
399
|
+
}
|
|
400
|
+
|
|
235
401
|
throw new Error(`Unknown tool "${String(name)}"`)
|
|
236
402
|
}
|
|
237
403
|
|
|
@@ -246,10 +412,10 @@ const writeFrame = (payload: unknown, framing: StdioFraming): void => {
|
|
|
246
412
|
process.stdout.write(framing === 'json-line' ? `${body}\n` : `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
|
|
247
413
|
}
|
|
248
414
|
|
|
249
|
-
const
|
|
415
|
+
export const respondMcpRequest = async (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming): Promise<void> => {
|
|
250
416
|
if (request.id === undefined) {
|
|
251
417
|
try {
|
|
252
|
-
handleMcpRequest(ctx, request)
|
|
418
|
+
await handleMcpRequest(ctx, request)
|
|
253
419
|
} catch {
|
|
254
420
|
// Notifications do not get responses.
|
|
255
421
|
}
|
|
@@ -257,7 +423,7 @@ const respond = (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming
|
|
|
257
423
|
}
|
|
258
424
|
|
|
259
425
|
try {
|
|
260
|
-
const result = handleMcpRequest(ctx, request)
|
|
426
|
+
const result = await handleMcpRequest(ctx, request)
|
|
261
427
|
writeFrame({ jsonrpc: '2.0', id: request.id, result: result ?? {} }, framing)
|
|
262
428
|
} catch (error) {
|
|
263
429
|
writeFrame({
|
|
@@ -270,6 +436,10 @@ const respond = (ctx: McpContext, request: JsonRpcRequest, framing: StdioFraming
|
|
|
270
436
|
|
|
271
437
|
export const startMcpStdioServer = (ctx: McpContext): void => {
|
|
272
438
|
let buffer = Buffer.alloc(0)
|
|
439
|
+
let responseChain = Promise.resolve()
|
|
440
|
+
const enqueueResponse = (request: JsonRpcRequest, framing: StdioFraming): void => {
|
|
441
|
+
responseChain = responseChain.then(() => respondMcpRequest(ctx, request, framing))
|
|
442
|
+
}
|
|
273
443
|
process.stdin.on('data', (chunk: Buffer) => {
|
|
274
444
|
buffer = Buffer.concat([buffer, chunk])
|
|
275
445
|
while (true) {
|
|
@@ -288,7 +458,7 @@ export const startMcpStdioServer = (ctx: McpContext): void => {
|
|
|
288
458
|
if (buffer.length < bodyEnd) return
|
|
289
459
|
const raw = buffer.subarray(bodyStart, bodyEnd).toString('utf8')
|
|
290
460
|
buffer = buffer.subarray(bodyEnd)
|
|
291
|
-
|
|
461
|
+
enqueueResponse(JSON.parse(raw) as JsonRpcRequest, 'content-length')
|
|
292
462
|
continue
|
|
293
463
|
}
|
|
294
464
|
|
|
@@ -298,7 +468,7 @@ export const startMcpStdioServer = (ctx: McpContext): void => {
|
|
|
298
468
|
buffer = buffer.subarray(lineEnd + 1)
|
|
299
469
|
if (!raw) continue
|
|
300
470
|
try {
|
|
301
|
-
|
|
471
|
+
enqueueResponse(JSON.parse(raw) as JsonRpcRequest, 'json-line')
|
|
302
472
|
} catch {
|
|
303
473
|
writeFrame({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, 'json-line')
|
|
304
474
|
}
|