@agentskit/doc-bridge 0.1.0-alpha.2 → 1.0.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +139 -57
  3. package/action.yml +78 -0
  4. package/dist/cli/program.js +1122 -114
  5. package/dist/cli/program.js.map +1 -1
  6. package/dist/index.d.ts +258 -28
  7. package/dist/index.js +824 -54
  8. package/dist/index.js.map +1 -1
  9. package/docs/DOGFOOD-ROUND2.md +142 -0
  10. package/docs/DOGFOOD-ROUND3.md +74 -0
  11. package/docs/POSITIONING.md +2 -0
  12. package/docs/RELEASE.md +5 -3
  13. package/docs/chat-and-rag.md +2 -0
  14. package/docs/getting-started.md +14 -1
  15. package/docs/landing/index.html +299 -0
  16. package/docs/mcp.md +10 -1
  17. package/docs/ollama-demo.md +64 -0
  18. package/docs/playbook/doc-bridge-pattern.md +114 -0
  19. package/docs/recipes/index-pipeline.md +89 -0
  20. package/docs/skills/doc-bridge.md +64 -0
  21. package/docs/spec/cli.md +6 -0
  22. package/docs/spec/playbook-feedback.md +12 -4
  23. package/examples/demo-example/doc-bridge.config.json +23 -0
  24. package/examples/demo-example/docs/for-agents/INDEX.md +3 -0
  25. package/examples/demo-example/docs/for-agents/packages/example.md +14 -0
  26. package/examples/demo-example/package.json +7 -0
  27. package/examples/demo-example/src/.gitkeep +0 -0
  28. package/examples/demo-monorepo/doc-bridge.config.json +37 -0
  29. package/examples/demo-monorepo/docs/for-agents/INDEX.md +4 -0
  30. package/examples/demo-monorepo/docs/for-agents/packages/auth.md +22 -0
  31. package/examples/demo-monorepo/docs/for-agents/packages/billing.md +19 -0
  32. package/examples/demo-monorepo/docs/human/guides/auth.md +7 -0
  33. package/examples/demo-monorepo/package.json +7 -0
  34. package/examples/demo-monorepo/packages/auth/package.json +8 -0
  35. package/examples/demo-monorepo/packages/billing/package.json +7 -0
  36. package/examples/demo-monorepo/pnpm-workspace.yaml +2 -0
  37. package/examples/ollama-chat.config.ts +42 -0
  38. package/package.json +12 -9
  39. package/src/cli/demo.ts +143 -0
  40. package/src/cli/program.ts +220 -26
  41. package/src/doctor/badge.ts +53 -0
  42. package/src/doctor/run-doctor.ts +286 -0
  43. package/src/federation/llms.ts +20 -11
  44. package/src/index-builder/build-handoffs.ts +35 -2
  45. package/src/index-builder/scan-corpus.ts +5 -1
  46. package/src/index-builder/watch-index.ts +94 -0
  47. package/src/index.ts +24 -0
  48. package/src/lib/markdown.ts +31 -4
  49. package/src/mcp/install.ts +84 -0
  50. package/src/memory/github-pr.ts +190 -0
  51. package/src/playbook/doc-bridge-pattern.ts +121 -0
  52. package/src/query/query.ts +19 -1
  53. package/src/query/search.ts +85 -17
  54. package/src/schemas/agent-handoff.ts +11 -0
  55. package/src/schemas/doc-bridge-index.ts +3 -1
  56. package/src/schemas/json-schemas.ts +2 -1
  57. package/src/version.ts +1 -1
@@ -0,0 +1,286 @@
1
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
2
+ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
3
+ import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
4
+ import { runGates, type GateRunResult } from '../gates/run-gates.js'
5
+ import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
6
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
7
+ import { doctorBadgeMetrics, type DoctorBadgeMetrics } from './badge.js'
8
+
9
+ export type DoctorIssue = {
10
+ readonly severity: 'error' | 'warn' | 'info'
11
+ readonly code: string
12
+ readonly message: string
13
+ readonly action?: string
14
+ }
15
+
16
+ export type DoctorCoverage = {
17
+ readonly packages: {
18
+ readonly total: number
19
+ readonly withAgentDoc: number
20
+ readonly withHumanDoc: number
21
+ readonly missingAgentDoc: readonly string[]
22
+ readonly missingHumanDoc: readonly string[]
23
+ }
24
+ readonly agentDocs: {
25
+ readonly total: number
26
+ readonly indexed: number
27
+ readonly unindexed: readonly string[]
28
+ }
29
+ readonly freshness: {
30
+ readonly ok: boolean
31
+ readonly message: string
32
+ readonly hasIndex: boolean
33
+ }
34
+ readonly gates: GateRunResult
35
+ }
36
+
37
+ export type DoctorReport = {
38
+ readonly ok: boolean
39
+ readonly score: number
40
+ readonly grade: 'A' | 'B' | 'C' | 'D' | 'F'
41
+ readonly coverage: DoctorCoverage
42
+ readonly badge: DoctorBadgeMetrics
43
+ readonly issues: readonly DoctorIssue[]
44
+ readonly nextActions: readonly string[]
45
+ }
46
+
47
+ const gradeForScore = (score: number): DoctorReport['grade'] => {
48
+ if (score >= 90) return 'A'
49
+ if (score >= 75) return 'B'
50
+ if (score >= 60) return 'C'
51
+ if (score >= 40) return 'D'
52
+ return 'F'
53
+ }
54
+
55
+ const agentDocPaths = (index: DocBridgeIndexV1): Set<string> => {
56
+ const paths = new Set<string>()
57
+ for (const owner of Object.values(index.lookup?.ownership ?? {})) {
58
+ if (owner.agentDoc) paths.add(owner.agentDoc)
59
+ }
60
+ for (const handoff of Object.values(index.handoffs ?? {})) {
61
+ if (handoff.startHere) paths.add(handoff.startHere)
62
+ }
63
+ return paths
64
+ }
65
+
66
+ const computeScore = (coverage: DoctorCoverage): number => {
67
+ let score = 0
68
+
69
+ if (coverage.freshness.hasIndex) score += 15
70
+ if (coverage.freshness.ok) score += 15
71
+
72
+ const { total, withAgentDoc, withHumanDoc } = coverage.packages
73
+ if (total > 0) {
74
+ score += Math.round((withAgentDoc / total) * 35)
75
+ score += Math.round((withHumanDoc / total) * 20)
76
+ } else if (coverage.agentDocs.indexed > 0) {
77
+ score += 35
78
+ }
79
+
80
+ if (coverage.gates.ok) score += 15
81
+ else {
82
+ const passed = coverage.gates.results.filter((gate) => gate.ok).length
83
+ const totalGates = coverage.gates.results.length || 1
84
+ score += Math.round((passed / totalGates) * 10)
85
+ }
86
+
87
+ return Math.min(100, Math.max(0, score))
88
+ }
89
+
90
+ const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
91
+ const issues: DoctorIssue[] = []
92
+
93
+ if (!coverage.freshness.hasIndex) {
94
+ issues.push({
95
+ severity: 'error',
96
+ code: 'index-missing',
97
+ message: 'No doc-bridge index found.',
98
+ action: 'ak-docs index',
99
+ })
100
+ } else if (!coverage.freshness.ok) {
101
+ issues.push({
102
+ severity: 'error',
103
+ code: 'index-stale',
104
+ message: coverage.freshness.message,
105
+ action: 'ak-docs index',
106
+ })
107
+ }
108
+
109
+ for (const id of coverage.packages.missingAgentDoc) {
110
+ issues.push({
111
+ severity: 'warn',
112
+ code: 'missing-agent-doc',
113
+ message: `Package "${id}" has no dedicated agent doc.`,
114
+ action: `ak-docs init --scaffold-workspaces # or edit docs/for-agents/packages/${id}.md`,
115
+ })
116
+ }
117
+
118
+ for (const id of coverage.packages.missingHumanDoc) {
119
+ issues.push({
120
+ severity: 'info',
121
+ code: 'missing-human-doc',
122
+ message: `Package "${id}" has no linked human guide.`,
123
+ action: 'ak-docs bootstrap agent-docs',
124
+ })
125
+ }
126
+
127
+ for (const gate of coverage.gates.results.filter((result) => !result.ok)) {
128
+ issues.push({
129
+ severity: gate.id === 'index-freshness' ? 'error' : 'warn',
130
+ code: `gate-${gate.id}`,
131
+ message: gate.message,
132
+ action: gate.id === 'index-freshness' ? 'ak-docs index' : 'ak-docs gate run',
133
+ })
134
+ }
135
+
136
+ return issues
137
+ }
138
+
139
+ const buildNextActions = (issues: readonly DoctorIssue[], coverage: DoctorCoverage): string[] => {
140
+ const actions = new Set<string>()
141
+ for (const issue of issues) {
142
+ if (issue.action) actions.add(issue.action)
143
+ }
144
+
145
+ if (!coverage.freshness.hasIndex || !coverage.freshness.ok) {
146
+ actions.add('ak-docs index')
147
+ }
148
+ if (coverage.packages.missingHumanDoc.length) {
149
+ actions.add('ak-docs bootstrap agent-docs')
150
+ }
151
+ if (coverage.packages.total > 0) {
152
+ const sample =
153
+ coverage.packages.missingAgentDoc[0] ??
154
+ coverage.packages.missingHumanDoc[0]
155
+ if (sample) actions.add(`ak-docs query package ${sample} --agent`)
156
+ }
157
+ if (!actions.size) {
158
+ actions.add('ak-docs mcp install --cursor')
159
+ actions.add('ak-docs gate run')
160
+ }
161
+
162
+ return [...actions].slice(0, 6)
163
+ }
164
+
165
+ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport => {
166
+ let index: DocBridgeIndexV1 | undefined
167
+ let hasIndex = true
168
+ let freshnessOk = false
169
+ let freshnessMessage = 'Index is fresh'
170
+
171
+ try {
172
+ index = loadDocBridgeIndex(root, config)
173
+ const next = buildDocBridgeIndex({ root, config, write: false }).index.contentHash
174
+ freshnessOk = index.contentHash === next
175
+ freshnessMessage = freshnessOk ? 'Index is fresh' : 'Index is stale. Run: ak-docs index'
176
+ } catch (error) {
177
+ if (error instanceof IndexNotFoundError) {
178
+ hasIndex = false
179
+ freshnessOk = false
180
+ freshnessMessage = error.message
181
+ index = buildDocBridgeIndex({ root, config, write: false }).index
182
+ } else {
183
+ throw error
184
+ }
185
+ }
186
+
187
+ const ownership = Object.entries(index.lookup?.ownership ?? {})
188
+ const missingAgentDoc = ownership
189
+ .filter(([, owner]) => !owner.agentDoc || owner.agentDoc === config.corpus.agent.index)
190
+ .map(([id]) => id)
191
+ const missingHumanDoc = ownership.filter(([, owner]) => !owner.humanDoc).map(([id]) => id)
192
+
193
+ const indexedPaths = new Set(index.knowledge.map((entry) => entry.path))
194
+ const expectedAgentDocs = agentDocPaths(index)
195
+ const unindexed = [...expectedAgentDocs].filter((path) => !indexedPaths.has(path))
196
+
197
+ const corpusDocs = scanAgentCorpus(root, config).filter(
198
+ (doc) => doc.path !== config.corpus.agent.index,
199
+ )
200
+
201
+ const gates = runGates(root, config)
202
+
203
+ const coverage: DoctorCoverage = {
204
+ packages: {
205
+ total: ownership.length,
206
+ withAgentDoc: ownership.length - missingAgentDoc.length,
207
+ withHumanDoc: ownership.length - missingHumanDoc.length,
208
+ missingAgentDoc,
209
+ missingHumanDoc,
210
+ },
211
+ agentDocs: {
212
+ total: corpusDocs.length,
213
+ indexed: corpusDocs.filter((doc) => indexedPaths.has(doc.path)).length,
214
+ unindexed: corpusDocs.filter((doc) => !indexedPaths.has(doc.path)).map((doc) => doc.path),
215
+ },
216
+ freshness: {
217
+ ok: freshnessOk,
218
+ message: freshnessMessage,
219
+ hasIndex,
220
+ },
221
+ gates,
222
+ }
223
+
224
+ const issues = buildIssues(coverage)
225
+ const score = computeScore(coverage)
226
+ const nextActions = buildNextActions(issues, coverage)
227
+
228
+ const report: DoctorReport = {
229
+ ok: issues.every((issue) => issue.severity !== 'error') && gates.ok,
230
+ score,
231
+ grade: gradeForScore(score),
232
+ coverage,
233
+ badge: { handoffPct: 0, bridgePct: 0, score, grade: gradeForScore(score), packages: 0 },
234
+ issues,
235
+ nextActions,
236
+ }
237
+ return { ...report, badge: doctorBadgeMetrics(report) }
238
+ }
239
+
240
+ export const formatDoctorText = (report: DoctorReport): string[] => {
241
+ const { coverage } = report
242
+ const handoffPct =
243
+ coverage.packages.total > 0
244
+ ? Math.round((coverage.packages.withAgentDoc / coverage.packages.total) * 100)
245
+ : 0
246
+ const humanPct =
247
+ coverage.packages.total > 0
248
+ ? Math.round((coverage.packages.withHumanDoc / coverage.packages.total) * 100)
249
+ : 0
250
+
251
+ const lines = [
252
+ 'doc-bridge doctor',
253
+ '─'.repeat(40),
254
+ `Score: ${report.score}/100 (${report.grade})`,
255
+ '',
256
+ 'Coverage',
257
+ ` Packages: ${coverage.packages.total}`,
258
+ ` Agent docs: ${coverage.packages.withAgentDoc}/${coverage.packages.total} (${handoffPct}% handoff-ready)`,
259
+ ` Human guides: ${coverage.packages.withHumanDoc}/${coverage.packages.total} (${humanPct}% bridged)`,
260
+ ` Corpus indexed: ${coverage.agentDocs.indexed}/${coverage.agentDocs.total} agent docs`,
261
+ ` Index freshness: ${coverage.freshness.ok ? 'fresh' : 'stale or missing'}`,
262
+ ` Gates: ${coverage.gates.results.filter((g) => g.ok).length}/${coverage.gates.results.length} passing`,
263
+ ` Badge: handoff ${report.badge.handoffPct}% · bridge ${report.badge.bridgePct}%`,
264
+ ]
265
+
266
+ if (coverage.packages.missingHumanDoc.length) {
267
+ lines.push('', 'Missing humanDoc (bridge gap)', ...coverage.packages.missingHumanDoc.map((id) => ` • ${id}`))
268
+ }
269
+ if (coverage.packages.missingAgentDoc.length) {
270
+ lines.push('', 'Missing agent doc', ...coverage.packages.missingAgentDoc.map((id) => ` • ${id}`))
271
+ }
272
+
273
+ if (report.issues.length) {
274
+ lines.push('', 'Issues')
275
+ for (const issue of report.issues.slice(0, 8)) {
276
+ const icon = issue.severity === 'error' ? '✗' : issue.severity === 'warn' ? '!' : '·'
277
+ lines.push(` ${icon} [${issue.code}] ${issue.message}`)
278
+ }
279
+ if (report.issues.length > 8) {
280
+ lines.push(` … +${report.issues.length - 8} more`)
281
+ }
282
+ }
283
+
284
+ lines.push('', 'Next actions', ...report.nextActions.map((action) => ` → ${action}`))
285
+ return lines
286
+ }
@@ -35,11 +35,15 @@ const sourceText = async (
35
35
  root: string,
36
36
  source: string,
37
37
  fetchText: FetchText,
38
- ): Promise<string> => {
39
- if (/^https?:\/\//.test(source)) return fetchText(source)
40
- const path = resolve(root, source)
41
- if (!existsSync(path)) throw new Error(`Federation source not found: ${source}`)
42
- return readFileSync(path, 'utf8')
38
+ ): Promise<string | null> => {
39
+ try {
40
+ if (/^https?:\/\//.test(source)) return await fetchText(source)
41
+ const path = resolve(root, source)
42
+ if (!existsSync(path)) return null
43
+ return readFileSync(path, 'utf8')
44
+ } catch {
45
+ return null
46
+ }
43
47
  }
44
48
 
45
49
  const sameOrigin = (base: string, target: string): boolean => {
@@ -97,9 +101,14 @@ export const loadFederatedChunks = async (
97
101
  ): Promise<DocBridgeRetrievedChunk[]> => {
98
102
  const fetchText = options.fetchText ?? defaultFetchText
99
103
  const chunks: DocBridgeRetrievedChunk[] = []
104
+ const warnings: string[] = []
100
105
  for (const source of config.federation?.sources ?? []) {
101
106
  if (source.includeInRetriever === false || !source.llmsTxt) continue
102
107
  const llms = await sourceText(root, source.llmsTxt, fetchText)
108
+ if (!llms) {
109
+ warnings.push(`federation source skipped (unavailable): ${source.id} → ${source.llmsTxt}`)
110
+ continue
111
+ }
103
112
  chunks.push(...chunksFromMarkdown(source.id, llms, source.llmsTxt))
104
113
  const links = parseLlmsTxtLinks(llms)
105
114
  for (const link of links) {
@@ -108,14 +117,14 @@ export const loadFederatedChunks = async (
108
117
  : link.url
109
118
  if (!/\.(md|txt)(?:$|\?)/.test(url)) continue
110
119
  if (!sameOrigin(source.llmsTxt, url)) continue
111
- try {
112
- const raw = await sourceText(root, url, fetchText)
113
- chunks.push(...chunksFromMarkdown(source.id, raw, url))
114
- } catch {
115
- // ponytail: federation is best-effort; surface-level health belongs in a future diagnostic command.
116
- }
120
+ const raw = await sourceText(root, url, fetchText)
121
+ if (raw) chunks.push(...chunksFromMarkdown(source.id, raw, url))
117
122
  }
118
123
  }
124
+ // Soft-fail: never throw for missing remote sources; one-line warn for agents/humans.
125
+ if (warnings.length && process.stderr.isTTY) {
126
+ for (const w of warnings) process.stderr.write(`${w}\n`)
127
+ }
119
128
  return chunks
120
129
  }
121
130
 
@@ -103,16 +103,31 @@ const resolveHumanDoc = (
103
103
  if (override) return override
104
104
  if (fmHuman) return fmHuman
105
105
  if (humanDocs[packageId]) return humanDocs[packageId]
106
- // common aliases: scoped package name tail, path segments
106
+
107
107
  const aliases = [
108
108
  packageId,
109
109
  packageId.replace(/^@[^/]+\//, ''),
110
110
  packageId.replace(/^os-/, ''),
111
111
  packageId.replace(/-pattern$/, ''),
112
+ `packages/${packageId}`,
113
+ `reference/packages/${packageId}`,
114
+ `packages/${packageId}/index`,
112
115
  ]
113
116
  for (const alias of aliases) {
114
117
  if (humanDocs[alias]) return humanDocs[alias]
115
118
  }
119
+
120
+ // Fuzzy: id is a path segment or suffix of a human doc id
121
+ for (const [key, url] of Object.entries(humanDocs)) {
122
+ if (
123
+ key === packageId ||
124
+ key.endsWith(`/${packageId}`) ||
125
+ key.endsWith(`/${packageId}/index`) ||
126
+ key.endsWith(`-${packageId}`)
127
+ ) {
128
+ return url
129
+ }
130
+ }
116
131
  return undefined
117
132
  }
118
133
 
@@ -168,6 +183,18 @@ export const buildLookup = (
168
183
  }
169
184
  ownership[pkg.id] = record
170
185
 
186
+ const bridge = record.humanDoc
187
+ ? /^https?:\/\//.test(record.humanDoc)
188
+ ? { humanDoc: 'external' as const }
189
+ : { humanDoc: 'linked' as const }
190
+ : config.corpus.human
191
+ ? {
192
+ humanDoc: 'missing' as const,
193
+ action: 'ak-docs bootstrap agent-docs',
194
+ bootstrap: `docs/for-agents/human/${pkg.id}.md`,
195
+ }
196
+ : undefined
197
+
171
198
  handoffs[pkg.id] = {
172
199
  type: 'agent-handoff',
173
200
  schemaVersion: 1,
@@ -186,7 +213,13 @@ export const buildLookup = (
186
213
  editRoots: [record.path],
187
214
  checks: [...record.checks],
188
215
  ...(record.humanDoc ? { humanDoc: record.humanDoc } : {}),
189
- notes: record.purpose ? [record.purpose] : [],
216
+ ...(bridge ? { bridge } : {}),
217
+ notes: [
218
+ ...(record.purpose ? [record.purpose] : []),
219
+ ...(!record.humanDoc && config.corpus.human
220
+ ? [`Human guide missing for ${pkg.id}. Run: ak-docs bootstrap agent-docs`]
221
+ : []),
222
+ ],
190
223
  }
191
224
  }
192
225
 
@@ -3,6 +3,7 @@ import { join } from 'node:path'
3
3
 
4
4
  import type { DocBridgeConfigV1 } from '../config/schema.js'
5
5
  import {
6
+ extractSearchBody,
6
7
  firstHeading,
7
8
  firstParagraph,
8
9
  frontmatterString,
@@ -47,7 +48,9 @@ export const scanAgentCorpus = (root: string, config: DocBridgeConfigV1): Corpus
47
48
  frontmatterString(frontmatter, 'package') ??
48
49
  slugFromPath(relToCorpus)
49
50
  const title = firstHeading(raw) ?? id
50
- const description = firstParagraph(raw)
51
+ const purpose = frontmatterString(frontmatter, 'purpose')
52
+ const description = purpose ?? firstParagraph(raw, 400)
53
+ const body = extractSearchBody(raw)
51
54
  return {
52
55
  id,
53
56
  type: 'agent-doc',
@@ -57,6 +60,7 @@ export const scanAgentCorpus = (root: string, config: DocBridgeConfigV1): Corpus
57
60
  relPath,
58
61
  frontmatter,
59
62
  ...(description ? { description } : {}),
63
+ ...(body ? { body } : {}),
60
64
  }
61
65
  })
62
66
  }
@@ -0,0 +1,94 @@
1
+ import { existsSync, watch } from 'node:fs'
2
+ import { dirname, join, resolve } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { buildDocBridgeIndex } from './build-index.js'
6
+
7
+ export type WatchIndexOptions = {
8
+ readonly root: string
9
+ readonly config: DocBridgeConfigV1
10
+ readonly configPath?: string
11
+ readonly debounceMs?: number
12
+ readonly onRebuild?: (summary: { knowledgeCount: number; handoffCount: number; hash: string }) => void
13
+ }
14
+
15
+ const WATCH_PATTERN = /\.(md|mdx|json|ya?ml|mdc)$/i
16
+
17
+ const collectWatchRoots = (root: string, config: DocBridgeConfigV1, configPath?: string): string[] => {
18
+ const roots = new Set<string>()
19
+ roots.add(resolve(root, config.corpus.agent.root))
20
+ const humanSources = config.corpus.human
21
+ ? Array.isArray(config.corpus.human)
22
+ ? config.corpus.human
23
+ : [config.corpus.human]
24
+ : []
25
+ for (const source of humanSources) {
26
+ const humanOpts = source.options ?? {}
27
+ for (const key of ['contentDir', 'docsDir', 'root']) {
28
+ const value = humanOpts[key]
29
+ if (typeof value === 'string' && value.length) roots.add(resolve(root, value))
30
+ }
31
+ }
32
+ if (configPath) roots.add(dirname(resolve(configPath)))
33
+ return [...roots].filter((dir) => existsSync(dir))
34
+ }
35
+
36
+ export const watchDocBridgeIndex = (opts: WatchIndexOptions): Promise<number> => {
37
+ const debounceMs = opts.debounceMs ?? 350
38
+ let timer: ReturnType<typeof setTimeout> | undefined
39
+ let running = false
40
+
41
+ const rebuild = (): void => {
42
+ if (timer) clearTimeout(timer)
43
+ timer = setTimeout(() => {
44
+ if (running) return
45
+ running = true
46
+ try {
47
+ const result = buildDocBridgeIndex({ root: opts.root, config: opts.config })
48
+ const handoffCount = Object.keys(result.index.handoffs ?? {}).length
49
+ const summary = {
50
+ knowledgeCount: result.index.knowledge.length,
51
+ handoffCount,
52
+ hash: result.index.contentHash.slice(0, 8),
53
+ }
54
+ opts.onRebuild?.(summary)
55
+ process.stdout.write(
56
+ `[ak-docs] indexed ${summary.knowledgeCount} docs, ${summary.handoffCount} handoffs (${summary.hash}…)\n`,
57
+ )
58
+ } catch (error) {
59
+ process.stderr.write(
60
+ `[ak-docs] index failed: ${error instanceof Error ? error.message : String(error)}\n`,
61
+ )
62
+ } finally {
63
+ running = false
64
+ }
65
+ }, debounceMs)
66
+ }
67
+
68
+ rebuild()
69
+
70
+ for (const dir of collectWatchRoots(opts.root, opts.config, opts.configPath)) {
71
+ watch(dir, { recursive: true }, (_event, filename) => {
72
+ if (!filename || !WATCH_PATTERN.test(filename)) return
73
+ rebuild()
74
+ })
75
+ }
76
+
77
+ const configDir = resolve(opts.root)
78
+ if (existsSync(configDir)) {
79
+ watch(configDir, (_event, filename) => {
80
+ if (!filename || !/doc-bridge\.config/.test(filename)) return
81
+ rebuild()
82
+ })
83
+ }
84
+
85
+ process.stdout.write(
86
+ `[ak-docs] watching ${collectWatchRoots(opts.root, opts.config, opts.configPath).join(', ') || opts.root} (Ctrl+C to stop)\n`,
87
+ )
88
+
89
+ return new Promise((resolvePromise) => {
90
+ const onSignal = () => resolvePromise(0)
91
+ process.once('SIGINT', onSignal)
92
+ process.once('SIGTERM', onSignal)
93
+ })
94
+ }
package/src/index.ts CHANGED
@@ -20,7 +20,9 @@ export {
20
20
  HandoffTargetTypeSchema,
21
21
  HANDOFF_SCHEMA_VERSION,
22
22
  normalizeAgentHandoff,
23
+ HandoffBridgeSchema,
23
24
  type AgentHandoffV1,
25
+ type HandoffBridge,
24
26
  type AgentSearchV1,
25
27
  type HandoffTarget,
26
28
  type HandoffTargetType,
@@ -74,6 +76,22 @@ export {
74
76
  type GateRunResult,
75
77
  } from './gates/run-gates.js'
76
78
  export { MCP_TOOLS, handleMcpRequest, startMcpStdioServer } from './mcp/server.js'
79
+ export { installMcpConfig, mcpSnippet, type McpInstallResult, type McpInstallTarget } from './mcp/install.js'
80
+ export { runDoctor, formatDoctorText, type DoctorReport, type DoctorIssue, type DoctorCoverage } from './doctor/run-doctor.js'
81
+ export {
82
+ doctorBadgeMetrics,
83
+ formatDoctorBadgeJson,
84
+ formatDoctorBadgeMarkdown,
85
+ type DoctorBadgeMetrics,
86
+ } from './doctor/badge.js'
87
+ export { watchDocBridgeIndex, type WatchIndexOptions } from './index-builder/watch-index.js'
88
+ export {
89
+ promoteMemoryToGithubPr,
90
+ writePromotionDraft,
91
+ defaultPromotionDraftPath,
92
+ type GithubPrOptions,
93
+ type GithubPrResult,
94
+ } from './memory/github-pr.js'
77
95
  export { sha256NormalizedV1 } from './index-builder/content-hash.js'
78
96
  export { IndexNotFoundError, indexFilePath, loadDocBridgeIndex, resolveRoot } from './query/load-index.js'
79
97
  export { runQuery, type QueryKind, type QueryRequest, type QueryResult } from './query/query.js'
@@ -118,3 +136,9 @@ export { projectRootFromConfigPath } from './config/load-config.js'
118
136
  export { createDocBridgeRag } from './intelligence/rag.js'
119
137
  export { runChatOnce, startInkChat } from './intelligence/chat.js'
120
138
  export { PeerMissingError, layer1InstallHint } from './intelligence/peers.js'
139
+ export {
140
+ DOC_BRIDGE_PATTERN_ID,
141
+ DOC_BRIDGE_PATTERN_META,
142
+ docBridgePatternMarkdown,
143
+ docBridgePatternPayload,
144
+ } from './playbook/doc-bridge-pattern.js'
@@ -64,7 +64,8 @@ export const firstHeading = (markdown: string): string | undefined => {
64
64
  return undefined
65
65
  }
66
66
 
67
- export const firstParagraph = (markdown: string): string | undefined => {
67
+ /** Complete first prose block prefer full sentences, cap length without mid-word cuts. */
68
+ export const firstParagraph = (markdown: string, maxLen = 400): string | undefined => {
68
69
  const { body } = parseFrontmatter(markdown)
69
70
  const lines = body.split('\n')
70
71
  const buf: string[] = []
@@ -76,11 +77,37 @@ export const firstParagraph = (markdown: string): string | undefined => {
76
77
  }
77
78
  if (t.startsWith('#')) continue
78
79
  if (t.startsWith('---')) continue
80
+ if (t.startsWith('```')) break
81
+ if (t.startsWith('|') || t.startsWith('- [') || t.startsWith('* [')) {
82
+ if (buf.length) break
83
+ continue
84
+ }
79
85
  buf.push(t)
80
- if (buf.join(' ').length > 40) break
86
+ if (buf.join(' ').length >= maxLen) break
81
87
  }
82
- const text = buf.join(' ').trim()
83
- return text || undefined
88
+ let text = buf.join(' ').replace(/\s+/g, ' ').trim()
89
+ if (!text) return undefined
90
+ if (text.length <= maxLen) return text
91
+ // Prefer ending on sentence boundary
92
+ const sliced = text.slice(0, maxLen)
93
+ const sentenceEnd = Math.max(sliced.lastIndexOf('. '), sliced.lastIndexOf('! '), sliced.lastIndexOf('? '))
94
+ if (sentenceEnd > maxLen * 0.4) return sliced.slice(0, sentenceEnd + 1).trim()
95
+ const wordEnd = sliced.lastIndexOf(' ')
96
+ return (wordEnd > 0 ? sliced.slice(0, wordEnd) : sliced).trim()
97
+ }
98
+
99
+ /** Flatten markdown body for search (strip fences/links noise lightly). */
100
+ export const extractSearchBody = (markdown: string, maxLen = 6_000): string => {
101
+ const { body } = parseFrontmatter(markdown)
102
+ const text = body
103
+ .replace(/```[\s\S]*?```/g, ' ')
104
+ .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
105
+ .replace(/\[[^\]]*\]\([^)]+\)/g, ' ')
106
+ .replace(/^#+\s+/gm, '')
107
+ .replace(/[|>*_`#]/g, ' ')
108
+ .replace(/\s+/g, ' ')
109
+ .trim()
110
+ return text.length > maxLen ? text.slice(0, maxLen) : text
84
111
  }
85
112
 
86
113
  export const slugFromPath = (relPath: string): string => {