@agentskit/doc-bridge 0.1.0-alpha.3 → 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 (50) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +139 -57
  3. package/action.yml +78 -0
  4. package/dist/cli/program.js +987 -69
  5. package/dist/cli/program.js.map +1 -1
  6. package/dist/index.d.ts +238 -26
  7. package/dist/index.js +704 -22
  8. package/dist/index.js.map +1 -1
  9. package/docs/DOGFOOD-ROUND3.md +74 -0
  10. package/docs/POSITIONING.md +2 -0
  11. package/docs/RELEASE.md +5 -3
  12. package/docs/chat-and-rag.md +2 -0
  13. package/docs/getting-started.md +14 -1
  14. package/docs/landing/index.html +299 -0
  15. package/docs/mcp.md +10 -1
  16. package/docs/ollama-demo.md +64 -0
  17. package/docs/playbook/doc-bridge-pattern.md +114 -0
  18. package/docs/recipes/index-pipeline.md +89 -0
  19. package/docs/skills/doc-bridge.md +64 -0
  20. package/docs/spec/cli.md +6 -0
  21. package/docs/spec/playbook-feedback.md +12 -4
  22. package/examples/demo-example/doc-bridge.config.json +23 -0
  23. package/examples/demo-example/docs/for-agents/INDEX.md +3 -0
  24. package/examples/demo-example/docs/for-agents/packages/example.md +14 -0
  25. package/examples/demo-example/package.json +7 -0
  26. package/examples/demo-example/src/.gitkeep +0 -0
  27. package/examples/demo-monorepo/doc-bridge.config.json +37 -0
  28. package/examples/demo-monorepo/docs/for-agents/INDEX.md +4 -0
  29. package/examples/demo-monorepo/docs/for-agents/packages/auth.md +22 -0
  30. package/examples/demo-monorepo/docs/for-agents/packages/billing.md +19 -0
  31. package/examples/demo-monorepo/docs/human/guides/auth.md +7 -0
  32. package/examples/demo-monorepo/package.json +7 -0
  33. package/examples/demo-monorepo/packages/auth/package.json +8 -0
  34. package/examples/demo-monorepo/packages/billing/package.json +7 -0
  35. package/examples/demo-monorepo/pnpm-workspace.yaml +2 -0
  36. package/examples/ollama-chat.config.ts +42 -0
  37. package/package.json +5 -2
  38. package/src/cli/demo.ts +143 -0
  39. package/src/cli/program.ts +194 -14
  40. package/src/doctor/badge.ts +53 -0
  41. package/src/doctor/run-doctor.ts +286 -0
  42. package/src/index-builder/build-handoffs.ts +19 -1
  43. package/src/index-builder/watch-index.ts +94 -0
  44. package/src/index.ts +24 -0
  45. package/src/mcp/install.ts +84 -0
  46. package/src/memory/github-pr.ts +190 -0
  47. package/src/playbook/doc-bridge-pattern.ts +121 -0
  48. package/src/query/query.ts +19 -1
  49. package/src/schemas/agent-handoff.ts +11 -0
  50. package/src/version.ts +1 -1
@@ -15,6 +15,16 @@ import { createDocBridgeRag } from '../intelligence/rag.js'
15
15
  import { firstHeading, firstParagraph } from '../lib/markdown.js'
16
16
  import { ingestMemoryCandidates } from '../memory/ingest.js'
17
17
  import { classifyMemoryCandidates, draftMemoryPromotion } from '../memory/pipeline.js'
18
+ import { promoteMemoryToGithubPr } from '../memory/github-pr.js'
19
+ import { watchDocBridgeIndex } from '../index-builder/watch-index.js'
20
+ import {
21
+ formatDoctorBadgeJson,
22
+ formatDoctorBadgeMarkdown,
23
+ } from '../doctor/badge.js'
24
+ import { docBridgePatternMarkdown, docBridgePatternPayload } from '../playbook/doc-bridge-pattern.js'
25
+ import { formatDemoText, runDemo, withDemoWorkspace, type DemoFixture } from './demo.js'
26
+ import { formatDoctorText, runDoctor } from '../doctor/run-doctor.js'
27
+ import { installMcpConfig, mcpSnippet } from '../mcp/install.js'
18
28
  import { startMcpStdioServer } from '../mcp/server.js'
19
29
  import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
20
30
  import { runQuery, type QueryKind } from '../query/query.js'
@@ -36,6 +46,8 @@ type Command =
36
46
  | 'index'
37
47
  | 'gate'
38
48
  | 'mcp'
49
+ | 'doctor'
50
+ | 'demo'
39
51
  | 'query'
40
52
  | 'search'
41
53
  | 'retrieve'
@@ -48,14 +60,17 @@ const usage = `ak-docs — human↔agent documentation bridge (@agentskit/doc-br
48
60
 
49
61
  Core (no API key):
50
62
  ak-docs init [--demo] [--scaffold-workspaces]
51
- ak-docs index
63
+ ak-docs demo [--fixture example|monorepo] [--text] [--in-project]
64
+ ak-docs doctor [--text] [--badge] [--write-badge]
65
+ ak-docs index [--watch]
52
66
  ak-docs query <package|ownership|intent|change> <id> [--agent] [--text]
53
67
  ak-docs search <term> [--agent] [--text]
54
68
  ak-docs list <packages|intents|changes|knowledge> [--text]
55
69
  ak-docs ask [question] local consult (no LLM)
56
70
  ak-docs gate run [gate-id]
57
71
  ak-docs mcp
58
- ak-docs memory ingest|classify|promote
72
+ ak-docs mcp install --cursor | --claude
73
+ ak-docs memory ingest|classify|promote [--pr] [--dry-run]
59
74
  ak-docs bootstrap agent-docs
60
75
  ak-docs validate-config | validate-handoff <file>
61
76
 
@@ -67,7 +82,7 @@ Intelligence (optional AgentsKit peers):
67
82
  Advanced / ecosystem:
68
83
  ak-docs retrieve <query>
69
84
  ak-docs registry topology
70
- ak-docs playbook draft
85
+ ak-docs playbook draft | pattern [--text]
71
86
 
72
87
  Global flags:
73
88
  -h, --help --version
@@ -112,6 +127,8 @@ const parseArgs = (argv: readonly string[]) => {
112
127
  else if (positional[0] === 'index') command = 'index'
113
128
  else if (positional[0] === 'gate') command = 'gate'
114
129
  else if (positional[0] === 'mcp') command = 'mcp'
130
+ else if (positional[0] === 'doctor') command = 'doctor'
131
+ else if (positional[0] === 'demo') command = 'demo'
115
132
  else if (positional[0] === 'query') command = 'query'
116
133
  else if (positional[0] === 'search') command = 'search'
117
134
  else if (positional[0] === 'retrieve') command = 'retrieve'
@@ -201,23 +218,60 @@ const writeTextSearch = (
201
218
  ])
202
219
  }
203
220
 
221
+ const handoffSummaryLines = (
222
+ index: DocBridgeIndexV1,
223
+ config: DocBridgeConfigV1,
224
+ ownerId: string,
225
+ ): string[] => {
226
+ try {
227
+ const handoff = runQuery(index, config, { kind: 'ownership', id: ownerId, agent: true })
228
+ if (!handoff || typeof handoff !== 'object' || !('editRoots' in handoff)) return []
229
+ const payload = handoff as {
230
+ startHere?: string
231
+ editRoots?: string[]
232
+ checks?: string[]
233
+ humanDoc?: string | null
234
+ bridge?: { humanDoc?: string; action?: string }
235
+ }
236
+ const bridgeLine =
237
+ payload.bridge?.humanDoc === 'missing'
238
+ ? `Bridge: human guide missing → ${payload.bridge.action ?? 'ak-docs bootstrap agent-docs'}`
239
+ : payload.humanDoc
240
+ ? `Bridge: ${payload.humanDoc}`
241
+ : 'Bridge: (no human plugin configured)'
242
+ return [
243
+ '',
244
+ 'Handoff preview',
245
+ ` start: ${payload.startHere ?? '(unknown)'}`,
246
+ ` edit: ${(payload.editRoots ?? []).join(', ') || '(none)'}`,
247
+ ` checks: ${(payload.checks ?? []).join(' · ') || '(none)'}`,
248
+ ` ${bridgeLine}`,
249
+ ]
250
+ } catch {
251
+ return []
252
+ }
253
+ }
254
+
204
255
  const writeAsk = (
205
256
  question: string,
206
257
  matches: ReturnType<typeof searchIndex>,
207
258
  index: DocBridgeIndexV1,
259
+ config: DocBridgeConfigV1,
208
260
  ): void => {
209
261
  // Prefer ownership match for routing questions
210
262
  const owner =
211
263
  matches.find((match) => match.type === 'ownership') ??
212
264
  matches.find((match) => Boolean(index.lookup?.ownership?.[match.id]))
213
265
  const best = owner ?? matches[0]
214
- const bestQuery =
215
- best && (best.type === 'ownership' || index.lookup?.ownership?.[best.id])
216
- ? `ak-docs query ownership ${best.id} --agent`
217
- : 'ak-docs list knowledge --text'
266
+ const ownerId =
267
+ best && (best.type === 'ownership' || index.lookup?.ownership?.[best.id]) ? best.id : undefined
268
+ const bestQuery = ownerId
269
+ ? `ak-docs query ownership ${ownerId} --agent`
270
+ : 'ak-docs list knowledge --text'
218
271
  writeLines([
219
272
  `Question: ${question}`,
220
273
  best ? `Best match: ${best.type} ${best.id} (${best.path})` : 'Best match: none',
274
+ ...(ownerId ? handoffSummaryLines(index, config, ownerId) : []),
221
275
  '',
222
276
  'Matches:',
223
277
  ...(
@@ -231,8 +285,9 @@ const writeAsk = (
231
285
  ? [
232
286
  `ak-docs search "${question}" --agent`,
233
287
  bestQuery,
288
+ ...(ownerId ? [`ak-docs doctor --text`] : []),
234
289
  ]
235
- : ['ak-docs list knowledge --text']),
290
+ : ['ak-docs list knowledge --text', 'ak-docs doctor --text']),
236
291
  ])
237
292
  }
238
293
 
@@ -271,7 +326,7 @@ const runAskRepl = async (root: string, config: DocBridgeConfigV1): Promise<numb
271
326
  const gateId = value || undefined
272
327
  writeJson(runGates(root, config, gateId ? [gateId as GateId] : undefined))
273
328
  } else {
274
- writeAsk(line, searchIndex(index, line, 8), index)
329
+ writeAsk(line, searchIndex(index, line, 8), index, config)
275
330
  }
276
331
  } catch (error) {
277
332
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
@@ -605,7 +660,9 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
605
660
 
606
661
  if (command === 'memory') {
607
662
  if (!['ingest', 'classify', 'promote'].includes(positional[1] ?? '')) {
608
- process.stderr.write('Usage: ak-docs memory <ingest|classify|promote> [--config <path>]\n')
663
+ process.stderr.write(
664
+ 'Usage: ak-docs memory <ingest|classify|promote> [--pr] [--dry-run] [--force] [--config <path>]\n',
665
+ )
609
666
  return 1
610
667
  }
611
668
  try {
@@ -622,6 +679,23 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
622
679
  return 0
623
680
  }
624
681
  const draft = draftMemoryPromotion(classifications)
682
+ if (flags.has('--pr') || flags.has('--github')) {
683
+ const pr = promoteMemoryToGithubPr(root, draft, {
684
+ dryRun: flags.has('--dry-run'),
685
+ force: flags.has('--force'),
686
+ })
687
+ if (flags.has('--text')) {
688
+ writeLines([
689
+ pr.message,
690
+ ...(pr.draftPath ? [`Draft: ${pr.draftPath}`] : []),
691
+ ...(pr.prUrl ? [`PR: ${pr.prUrl}`] : []),
692
+ ...(pr.commands.length ? ['', 'Commands:', ...pr.commands.map((cmd) => ` ${cmd}`)] : []),
693
+ ])
694
+ } else {
695
+ writeJson({ ...draft, pr })
696
+ }
697
+ return pr.ok ? 0 : 1
698
+ }
625
699
  writeJson(draft)
626
700
  return draft.ok ? 0 : 1
627
701
  } catch (error) {
@@ -640,8 +714,18 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
640
714
  }
641
715
 
642
716
  if (command === 'playbook') {
643
- if (positional[1] !== 'draft') {
644
- process.stderr.write('Usage: ak-docs playbook draft [--config <path>]\n')
717
+ const action = positional[1]
718
+ if (action === 'pattern') {
719
+ const payload = docBridgePatternPayload()
720
+ if (flags.has('--text') || wantsTextOutput(flags, { schemaVersion: 1, corpus: { agent: { root: 'docs' } } } as DocBridgeConfigV1)) {
721
+ process.stdout.write(`${docBridgePatternMarkdown()}\n`)
722
+ } else {
723
+ writeJson(payload)
724
+ }
725
+ return 0
726
+ }
727
+ if (action !== 'draft') {
728
+ process.stderr.write('Usage: ak-docs playbook draft | pattern [--text] [--config <path>]\n')
645
729
  return 1
646
730
  }
647
731
  try {
@@ -652,6 +736,8 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
652
736
  ...draft,
653
737
  title: 'Draft Playbook feedback promotion',
654
738
  pattern: 'Doc Bridge Pattern',
739
+ patternDoc: 'docs/playbook/doc-bridge-pattern.md',
740
+ exportCommand: 'ak-docs playbook pattern --text',
655
741
  })
656
742
  return 0
657
743
  } catch (error) {
@@ -662,7 +748,14 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
662
748
 
663
749
  if (command === 'index') {
664
750
  try {
665
- const { config, root } = loadProject(configPath)
751
+ const { config, root, configPath: loadedConfigPath } = loadProject(configPath)
752
+ if (flags.has('--watch')) {
753
+ return watchDocBridgeIndex({
754
+ root,
755
+ config,
756
+ configPath: loadedConfigPath,
757
+ })
758
+ }
666
759
  const result = buildDocBridgeIndex({ root, config })
667
760
  const diagnostics = indexDiagnostics(config, result)
668
761
  const handoffCount = Object.keys(result.index.handoffs ?? {}).length
@@ -710,6 +803,34 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
710
803
  }
711
804
 
712
805
  if (command === 'mcp') {
806
+ if (positional[1] === 'install') {
807
+ const target = flags.has('--claude') ? 'claude' : flags.has('--cursor') ? 'cursor' : undefined
808
+ if (!target) {
809
+ process.stderr.write('Usage: ak-docs mcp install --cursor | --claude\n')
810
+ return 1
811
+ }
812
+ try {
813
+ const { config, root } = loadProject(configPath)
814
+ const result = installMcpConfig(root, target)
815
+ if (wantsTextOutput(flags, config)) {
816
+ writeLines([
817
+ `Installed MCP server "${result.serverName}" for ${result.target}`,
818
+ `Config: ${result.configPath}`,
819
+ ...(result.created ? ['Created new config file'] : ['Merged into existing config']),
820
+ '',
821
+ 'Next steps:',
822
+ ...result.nextSteps.map((step) => ` → ${step}`),
823
+ ])
824
+ } else {
825
+ writeJson({ ...result, snippet: mcpSnippet(root) })
826
+ }
827
+ return 0
828
+ } catch (error) {
829
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
830
+ return 1
831
+ }
832
+ }
833
+
713
834
  try {
714
835
  const { config, root } = loadProject(configPath)
715
836
  startMcpStdioServer({ root, config })
@@ -720,6 +841,65 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
720
841
  }
721
842
  }
722
843
 
844
+ if (command === 'doctor') {
845
+ try {
846
+ const { config, root } = loadProject(configPath)
847
+ const report = runDoctor(root, config)
848
+ if (flags.has('--write-badge')) {
849
+ const badgePath = resolve(root, '.doc-bridge', 'coverage-badge.json')
850
+ mkdirSync(dirname(badgePath), { recursive: true })
851
+ writeFileSync(badgePath, `${formatDoctorBadgeJson(report.badge)}\n`, 'utf8')
852
+ }
853
+ if (flags.has('--badge')) {
854
+ writeLines([formatDoctorBadgeMarkdown(report.badge)])
855
+ return 0
856
+ }
857
+ if (wantsTextOutput(flags, config)) writeLines(formatDoctorText(report))
858
+ else writeJson(report)
859
+ return report.ok ? 0 : 1
860
+ } catch (error) {
861
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
862
+ return 1
863
+ }
864
+ }
865
+
866
+ if (command === 'demo') {
867
+ const resolveDemoFixture = (): DemoFixture => {
868
+ const fixtureFlagIndex = positional.indexOf('--fixture')
869
+ if (fixtureFlagIndex >= 0) {
870
+ const value = positional[fixtureFlagIndex + 1]
871
+ if (value === 'monorepo' || value === 'example') return value
872
+ }
873
+ if (flags.has('--monorepo') || positional.includes('monorepo')) return 'monorepo'
874
+ if (positional[1] === 'monorepo') return 'monorepo'
875
+ return 'example'
876
+ }
877
+ const resolvedFixture = resolveDemoFixture()
878
+
879
+ try {
880
+ const useProject = flags.has('--in-project') || flags.has('--copy-fixture')
881
+ if (!useProject) {
882
+ const result = withDemoWorkspace(resolvedFixture, (root, config) =>
883
+ runDemo(root, config, resolvedFixture),
884
+ )
885
+ const textMode =
886
+ flags.has('--text') || (!flags.has('--json') && !flags.has('--agent'))
887
+ if (textMode) writeLines(formatDemoText(result))
888
+ else writeJson(result)
889
+ return result.ok ? 0 : 1
890
+ }
891
+
892
+ const { config, root } = loadProject(configPath)
893
+ const result = runDemo(root, config, resolvedFixture, { copyFixture: flags.has('--copy-fixture') })
894
+ if (wantsTextOutput(flags, config)) writeLines(formatDemoText(result))
895
+ else writeJson(result)
896
+ return result.ok ? 0 : 1
897
+ } catch (error) {
898
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
899
+ return 1
900
+ }
901
+ }
902
+
723
903
  if (command === 'query') {
724
904
  const kind = positional[1] as QueryKind | undefined
725
905
  const id = positional[2]
@@ -889,7 +1069,7 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
889
1069
  return 1
890
1070
  }
891
1071
  const index = loadDocBridgeIndex(root, config)
892
- writeAsk(question, searchIndex(index, question, 8), index)
1072
+ writeAsk(question, searchIndex(index, question, 8), index, config)
893
1073
  return 0
894
1074
  } catch (error) {
895
1075
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
@@ -0,0 +1,53 @@
1
+ import type { DoctorReport } from './run-doctor.js'
2
+
3
+ export type DoctorBadgeMetrics = {
4
+ readonly handoffPct: number
5
+ readonly bridgePct: number
6
+ readonly score: number
7
+ readonly grade: DoctorReport['grade']
8
+ readonly packages: number
9
+ }
10
+
11
+ export const doctorBadgeMetrics = (report: DoctorReport): DoctorBadgeMetrics => {
12
+ const { packages } = report.coverage
13
+ const handoffPct =
14
+ packages.total > 0 ? Math.round((packages.withAgentDoc / packages.total) * 100) : 0
15
+ const bridgePct =
16
+ packages.total > 0 ? Math.round((packages.withHumanDoc / packages.total) * 100) : 0
17
+
18
+ return {
19
+ handoffPct,
20
+ bridgePct,
21
+ score: report.score,
22
+ grade: report.grade,
23
+ packages: packages.total,
24
+ }
25
+ }
26
+
27
+ const badgeColor = (pct: number): string => {
28
+ if (pct >= 80) return '2ea44f'
29
+ if (pct >= 50) return 'dbab09'
30
+ return 'cb2431'
31
+ }
32
+
33
+ export const formatDoctorBadgeMarkdown = (metrics: DoctorBadgeMetrics): string => {
34
+ const handoff = `handoff_coverage-${metrics.handoffPct}%25-${badgeColor(metrics.handoffPct)}`
35
+ const bridge = `human_bridge-${metrics.bridgePct}%25-${badgeColor(metrics.bridgePct)}`
36
+ return [
37
+ `![handoff coverage](https://img.shields.io/badge/${handoff}?style=flat-square)`,
38
+ `![human bridge](https://img.shields.io/badge/${bridge}?style=flat-square)`,
39
+ `![doc-bridge score](https://img.shields.io/badge/doc--bridge_score-${metrics.score}%2F100-${badgeColor(metrics.score)}?style=flat-square)`,
40
+ ].join(' ')
41
+ }
42
+
43
+ export const formatDoctorBadgeJson = (metrics: DoctorBadgeMetrics): string =>
44
+ JSON.stringify(
45
+ {
46
+ schemaVersion: 1,
47
+ ...metrics,
48
+ markdown: formatDoctorBadgeMarkdown(metrics),
49
+ updatedAt: new Date().toISOString(),
50
+ },
51
+ null,
52
+ 2,
53
+ )
@@ -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
+ }